From 2d05692e2f39d5cfa7594b18e7e9d3037480ef1e Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Mon, 23 Jun 2025 10:53:21 -0400 Subject: [PATCH 01/45] Fix SambaNova 'created' field validation error - handle float timestamps (#11971) * Fix SambaNova 'created' field validation error SambaNova API returns 'created' field as float but LiteLLM expects integer. Added _safe_convert_created_field() helper to handle float->int conversion while maintaining backward compatibility with existing integer timestamps. * Update litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py --------- Co-authored-by: openhands --- .../convert_dict_to_response.py | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index fb310bf33c7..54adef9c958 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -40,6 +40,34 @@ from litellm.types.utils import ( from .get_headers import get_response_headers +def _safe_convert_created_field(created_value) -> int: + """ + Safely convert a 'created' field value to an integer. + + Some providers (like SambaNova) return the 'created' field as a float + (Unix timestamp with fractional seconds), but LiteLLM expects an integer. + + Args: + created_value: The value from response_object["created"] + + Returns: + int: Unix timestamp as integer + """ + if created_value is None: + return int(time.time()) + elif isinstance(created_value, int): + return created_value + elif isinstance(created_value, float): + return int(created_value) + else: + # for strings, etc + try: + return int(float(created_value)) + except (ValueError, TypeError): + # Fallback to current time if conversion fails + return int(time.time()) + + def convert_tool_call_to_json_mode( tool_calls: List[ChatCompletionMessageToolCall], convert_tool_call_to_json_mode: bool, @@ -133,7 +161,7 @@ async def convert_to_streaming_response_async(response_object: Optional[dict] = model_response_object.id = response_object["id"] if "created" in response_object: - model_response_object.created = response_object["created"] + model_response_object.created = _safe_convert_created_field(response_object["created"]) if "system_fingerprint" in response_object: model_response_object.system_fingerprint = response_object["system_fingerprint"] @@ -181,7 +209,7 @@ def convert_to_streaming_response(response_object: Optional[dict] = None): model_response_object.id = response_object["id"] if "created" in response_object: - model_response_object.created = response_object["created"] + model_response_object.created = _safe_convert_created_field(response_object["created"]) if "system_fingerprint" in response_object: model_response_object.system_fingerprint = response_object["system_fingerprint"] @@ -578,9 +606,7 @@ def convert_to_model_response_object( # noqa: PLR0915 usage_object = litellm.Usage(**response_object["usage"]) setattr(model_response_object, "usage", usage_object) if "created" in response_object: - model_response_object.created = response_object["created"] or int( - time.time() - ) + model_response_object.created = _safe_convert_created_field(response_object["created"]) if "id" in response_object: model_response_object.id = response_object["id"] or str(uuid.uuid4()) From 05b95eb7020db338aa38015234001925568f7a05 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 08:26:29 -0700 Subject: [PATCH 02/45] docs - Recommended Machine Specifications (#11980) --- docs/my-website/docs/proxy/prod.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/my-website/docs/proxy/prod.md b/docs/my-website/docs/proxy/prod.md index 7cbaf145552..a618e530796 100644 --- a/docs/my-website/docs/proxy/prod.md +++ b/docs/my-website/docs/proxy/prod.md @@ -49,7 +49,21 @@ Need Help or want dedicated support ? Talk to a founder [here]: (https://calendl ::: -## 2. On Kubernetes - Use 1 Uvicorn worker [Suggested CMD] +## 2. Recommended Machine Specifications + +For optimal performance in production, we recommend the following minimum machine specifications: + +| Resource | Recommended Value | +|----------|------------------| +| CPU | 2 vCPU | +| Memory | 4 GB RAM | + +These specifications provide: +- Sufficient compute power for handling concurrent requests +- Adequate memory for request processing and caching + + +## 3. On Kubernetes - Use 1 Uvicorn worker [Suggested CMD] Use this Docker `CMD`. This will start the proxy with 1 Uvicorn Async Worker @@ -59,7 +73,7 @@ CMD ["--port", "4000", "--config", "./proxy_server_config.yaml"] ``` -## 3. Use Redis 'port','host', 'password'. NOT 'redis_url' +## 4. Use Redis 'port','host', 'password'. NOT 'redis_url' If you decide to use Redis, DO NOT use 'redis_url'. We recommend using redis port, host, and password params. @@ -92,13 +106,13 @@ litellm_settings: password: os.environ/REDIS_PASSWORD ``` -## 4. Disable 'load_dotenv' +## 5. Disable 'load_dotenv' Set `export LITELLM_MODE="PRODUCTION"` This disables the load_dotenv() functionality, which will automatically load your environment credentials from the local `.env`. -## 5. If running LiteLLM on VPC, gracefully handle DB unavailability +## 6. If running LiteLLM on VPC, gracefully handle DB unavailability When running LiteLLM on a VPC (and inaccessible from the public internet), you can enable graceful degradation so that request processing continues even if the database is temporarily unavailable. @@ -125,7 +139,7 @@ When `allow_requests_on_db_unavailable` is set to `true`, LiteLLM will handle er | LiteLLM Budget Errors or Model Errors | ❌ Request will be blocked | Triggered when the DB is reachable but the authentication token is invalid, lacks access, or exceeds budget limits. | -## 6. Disable spend_logs & error_logs if not using the LiteLLM UI +## 7. Disable spend_logs & error_logs if not using the LiteLLM UI By default, LiteLLM writes several types of logs to the database: - Every LLM API request to the `LiteLLM_SpendLogs` table @@ -141,7 +155,7 @@ general_settings: [More information about what the Database is used for here](db_info) -## 7. Use Helm PreSync Hook for Database Migrations [BETA] +## 8. Use Helm PreSync Hook for Database Migrations [BETA] To ensure only one service manages database migrations, use our [Helm PreSync hook for Database Migrations](https://github.com/BerriAI/litellm/blob/main/deploy/charts/litellm-helm/templates/migrations-job.yaml). This ensures migrations are handled during `helm upgrade` or `helm install`, while LiteLLM pods explicitly disable migrations. @@ -169,7 +183,7 @@ To ensure only one service manages database migrations, use our [Helm PreSync ho ``` -## 8. Set LiteLLM Salt Key +## 9. Set LiteLLM Salt Key If you plan on using the DB, set a salt key for encrypting/decrypting variables in the DB. @@ -184,7 +198,7 @@ export LITELLM_SALT_KEY="sk-1234" [**See Code**](https://github.com/BerriAI/litellm/blob/036a6821d588bd36d170713dcf5a72791a694178/litellm/proxy/common_utils/encrypt_decrypt_utils.py#L15) -## 9. Use `prisma migrate deploy` +## 10. Use `prisma migrate deploy` Use this to handle db migrations across LiteLLM versions in production From e3ba888c63f3a1e5ff72712128c852c57568caf0 Mon Sep 17 00:00:00 2001 From: hsuyuming Date: Mon, 23 Jun 2025 09:43:20 -0600 Subject: [PATCH 03/45] fix: make response api support Azure Authentication method (#11941) * fix: make response api support Azure Authentication method 1. Support diverse Azure authentication methods 2. Use distinct headers for API key and Azure AD token base on this documentation (https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/responses?tabs=rest-api#generate-a-text-response) * fix: fix path issue * fix lint error * rename test_transformation.py to test_azure_transformation.py * change litellm_params as Optional type --- litellm/llms/azure/common_utils.py | 121 ++++++ .../llms/azure/responses/transformation.py | 24 +- .../llms/base_llm/responses/transformation.py | 5 +- litellm/llms/custom_httpx/llm_http_handler.py | 87 ++--- .../llms/openai/responses/transformation.py | 8 +- .../response/test_azure_transformation.py | 108 ++++++ .../llms/azure/test_azure_common_utils.py | 363 +++++++++++++++++- .../test_openai_responses_transformation.py | 15 +- 8 files changed, 658 insertions(+), 73 deletions(-) create mode 100644 tests/test_litellm/llms/azure/response/test_azure_transformation.py diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index ee154d2c8a1..ed62175289c 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -6,6 +6,7 @@ import httpx from openai import AsyncAzureOpenAI, AzureOpenAI import litellm +from litellm.types.router import GenericLiteLLMParams from litellm._logging import verbose_logger from litellm.caching.caching import DualCache from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -264,6 +265,126 @@ def select_azure_base_url_or_endpoint(azure_client_params: dict): return azure_client_params +def get_azure_ad_token( + litellm_params: GenericLiteLLMParams, +) -> Optional[str]: + """ + Get Azure AD token from various authentication methods. + + This function tries different methods to obtain an Azure AD token: + 1. From an existing token provider + 2. From Entra ID using tenant_id, client_id, and client_secret + 3. From username and password + 4. From OIDC token + 5. From a service principal with secret workflow + + Args: + litellm_params: Dictionary containing authentication parameters + - azure_ad_token_provider: Optional callable that returns a token + - azure_ad_token: Optional existing token + - tenant_id: Optional Azure tenant ID + - client_id: Optional Azure client ID + - client_secret: Optional Azure client secret + - azure_username: Optional Azure username + - azure_password: Optional Azure password + + Returns: + Azure AD token as string if successful, None otherwise + """ + # Extract parameters + azure_ad_token_provider = litellm_params.get("azure_ad_token_provider") + azure_ad_token = litellm_params.get("azure_ad_token", None) or get_secret_str( + "AZURE_AD_TOKEN" + ) + tenant_id = litellm_params.get("tenant_id", os.getenv("AZURE_TENANT_ID")) + client_id = litellm_params.get("client_id", os.getenv("AZURE_CLIENT_ID")) + client_secret = litellm_params.get( + "client_secret", os.getenv("AZURE_CLIENT_SECRET") + ) + azure_username = litellm_params.get("azure_username", os.getenv("AZURE_USERNAME")) + azure_password = litellm_params.get("azure_password", os.getenv("AZURE_PASSWORD")) + scope = litellm_params.get( + "azure_scope", + os.getenv("AZURE_SCOPE", "https://cognitiveservices.azure.com/.default"), + ) + if scope is None: + scope = "https://cognitiveservices.azure.com/.default" + + # Try to get token provider from Entra ID + if azure_ad_token_provider is None and tenant_id and client_id and client_secret: + verbose_logger.debug( + "Using Azure AD Token Provider from Entra ID for Azure Auth" + ) + azure_ad_token_provider = get_azure_ad_token_from_entra_id( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + scope=scope, + ) + + # Try to get token provider from username and password + if ( + azure_ad_token_provider is None + and azure_username + and azure_password + and client_id + ): + verbose_logger.debug("Using Azure Username and Password for Azure Auth") + azure_ad_token_provider = get_azure_ad_token_from_username_password( + azure_username=azure_username, + azure_password=azure_password, + client_id=client_id, + scope=scope, + ) + + # Try to get token from OIDC + if ( + client_id + and tenant_id + and azure_ad_token + and azure_ad_token.startswith("oidc/") + ): + verbose_logger.debug("Using Azure OIDC Token for Azure Auth") + azure_ad_token = get_azure_ad_token_from_oidc( + azure_ad_token=azure_ad_token, + azure_client_id=client_id, + azure_tenant_id=tenant_id, + scope=scope, + ) + # Try to get token provider from service principal + elif ( + azure_ad_token_provider is None + and litellm.enable_azure_ad_token_refresh is True + ): + verbose_logger.debug( + "Using Azure AD token provider based on Service Principal with Secret workflow for Azure Auth" + ) + try: + azure_ad_token_provider = get_azure_ad_token_provider(azure_scope=scope) + except ValueError: + verbose_logger.debug("Azure AD Token Provider could not be used.") + + # Execute the token provider to get the token if available + if azure_ad_token_provider and callable(azure_ad_token_provider): + try: + token = azure_ad_token_provider() + if not isinstance(token, str): + verbose_logger.error( + f"Azure AD token provider returned non-string value: {type(token)}" + ) + raise TypeError(f"Azure AD token must be a string, got {type(token)}") + else: + azure_ad_token = token + except TypeError: + # Re-raise TypeError directly + raise + except Exception as e: + verbose_logger.error(f"Error calling Azure AD token provider: {str(e)}") + raise RuntimeError(f"Failed to get Azure AD token: {str(e)}") from e + + return azure_ad_token + + class BaseAzureLLM(BaseOpenAILLM): def get_azure_openai_client( self, diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index da80aa3735e..d5425ef4391 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -4,6 +4,7 @@ import httpx import litellm from litellm._logging import verbose_logger +from litellm.llms.azure.common_utils import get_azure_ad_token from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import * @@ -21,24 +22,27 @@ else: class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def validate_environment( - self, - headers: dict, - model: str, - api_key: Optional[str] = None, + self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams] ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() api_key = ( - api_key + litellm_params.api_key or litellm.api_key or litellm.azure_key or get_secret_str("AZURE_OPENAI_API_KEY") or get_secret_str("AZURE_API_KEY") ) - headers.update( - { - "Authorization": f"Bearer {api_key}", - } - ) + if api_key: + headers["api-key"] = api_key + return headers + + ### Fallback to Azure AD token-based authentication if no API key is available + ### Retrieves Azure AD token and adds it to the Authorization header + azure_ad_token = get_azure_ad_token(litellm_params) + if azure_ad_token: + headers["Authorization"] = f"Bearer {azure_ad_token}" + return headers def get_complete_url( diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index b2a555086d8..e2f89da5e86 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -63,10 +63,7 @@ class BaseResponsesAPIConfig(ABC): @abstractmethod def validate_environment( - self, - headers: dict, - model: str, - api_key: Optional[str] = None, + self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams] ) -> dict: return {} diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 3a5490eee49..4ed0016d8b2 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1108,18 +1108,21 @@ class BaseLLMHTTPHandler: ) # Prepare the request - headers, complete_url, binary_data, json_data = ( - self._prepare_audio_transcription_request( - model=model, - audio_file=audio_file, - optional_params=optional_params, - litellm_params=litellm_params, - logging_obj=logging_obj, - api_key=api_key, - api_base=api_base, - headers=headers, - provider_config=provider_config, - ) + ( + headers, + complete_url, + binary_data, + json_data, + ) = self._prepare_audio_transcription_request( + model=model, + audio_file=audio_file, + optional_params=optional_params, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_key=api_key, + api_base=api_base, + headers=headers, + provider_config=provider_config, ) if client is None or not isinstance(client, HTTPHandler): @@ -1170,18 +1173,21 @@ class BaseLLMHTTPHandler: ) # Prepare the request - headers, complete_url, binary_data, json_data = ( - self._prepare_audio_transcription_request( - model=model, - audio_file=audio_file, - optional_params=optional_params, - litellm_params=litellm_params, - logging_obj=logging_obj, - api_key=api_key, - api_base=api_base, - headers=headers, - provider_config=provider_config, - ) + ( + headers, + complete_url, + binary_data, + json_data, + ) = self._prepare_audio_transcription_request( + model=model, + audio_file=audio_file, + optional_params=optional_params, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_key=api_key, + api_base=api_base, + headers=headers, + provider_config=provider_config, ) if client is None or not isinstance(client, AsyncHTTPHandler): @@ -1437,9 +1443,9 @@ class BaseLLMHTTPHandler: sync_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, headers=response_api_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=litellm_params, ) if extra_headers: @@ -1557,9 +1563,9 @@ class BaseLLMHTTPHandler: async_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, headers=response_api_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=litellm_params, ) if extra_headers: @@ -1678,9 +1684,7 @@ class BaseLLMHTTPHandler: async_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -1762,9 +1766,7 @@ class BaseLLMHTTPHandler: sync_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -1847,9 +1849,7 @@ class BaseLLMHTTPHandler: sync_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -1915,9 +1915,7 @@ class BaseLLMHTTPHandler: async_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -2008,9 +2006,7 @@ class BaseLLMHTTPHandler: sync_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -2078,9 +2074,7 @@ class BaseLLMHTTPHandler: async_httpx_client = client headers = responses_api_provider_config.validate_environment( - api_key=litellm_params.api_key, - headers=extra_headers or {}, - model="None", + headers=extra_headers or {}, model="None", litellm_params=litellm_params ) if extra_headers: @@ -2447,10 +2441,7 @@ class BaseLLMHTTPHandler: _is_async: bool = False, fake_stream: bool = False, litellm_metadata: Optional[Dict[str, Any]] = None, - ) -> Union[ - ImageResponse, - Coroutine[Any, Any, ImageResponse], - ]: + ) -> Union[ImageResponse, Coroutine[Any, Any, ImageResponse],]: """ Handles image edit requests. diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 871662eccaf..cf742bc52cb 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -92,13 +92,11 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return ResponsesAPIResponse(**raw_response_json) def validate_environment( - self, - headers: dict, - model: str, - api_key: Optional[str] = None, + self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams] ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() api_key = ( - api_key + litellm_params.api_key or litellm.api_key or litellm.openai_key or get_secret_str("OPENAI_API_KEY") diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py new file mode 100644 index 00000000000..605c681c4a4 --- /dev/null +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -0,0 +1,108 @@ +import os +import sys +from unittest.mock import patch + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig +from litellm.types.router import GenericLiteLLMParams + + +def test_validate_environment_api_key_within_litellm_params(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + litellm_params = GenericLiteLLMParams(api_key="test-api-key") + + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + + expected = {"api-key": "test-api-key"} + + assert result == expected + + +def test_validate_environment_api_key_within_litellm(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + + with patch("litellm.api_key", "test-api-key"): + litellm_params = GenericLiteLLMParams() + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + + expected = {"api-key": "test-api-key"} + + assert result == expected + + +def test_validate_environment_azure_key_within_litellm(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + + with patch("litellm.azure_key", "test-azure-key"): + litellm_params = GenericLiteLLMParams() + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + + expected = {"api-key": "test-azure-key"} + + assert result == expected + + +def test_validate_environment_azure_openai_api_key_within_secret_str(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + + with patch( + "litellm.llms.azure.responses.transformation.get_secret_str" + ) as mock_get_secret_str: + # Configure the mock to return "test-api-key" when called with "AZURE_OPENAI_API_KEY" + mock_get_secret_str.side_effect = ( + lambda key: "test-api-key" if key == "AZURE_OPENAI_API_KEY" else None + ) + + litellm_params = GenericLiteLLMParams() + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + expected = {"api-key": "test-api-key"} + + assert result == expected + + +def test_validate_environment_azure_api_key_within_secret_str(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + + with patch( + "litellm.llms.azure.responses.transformation.get_secret_str" + ) as mock_get_secret_str: + # Configure the mock to return "test-api-key" when called with "AZURE_API_KEY" + mock_get_secret_str.side_effect = ( + lambda key: "test-api-key" if key == "AZURE_API_KEY" else None + ) + + litellm_params = GenericLiteLLMParams() + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + expected = {"api-key": "test-api-key"} + + assert result == expected + + +def test_validate_environment_get_azure_ad_token(): + azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() + + with patch( + "litellm.llms.azure.responses.transformation.get_azure_ad_token" + ) as mock_get_azure_ad_token: + mock_get_azure_ad_token.side_effect = lambda key: "test-azure-ad-token" + + litellm_params = GenericLiteLLMParams(azure_ad_token="test-azure-ad-token") + result = azure_openai_responses_apiconfig.validate_environment( + headers={}, model="", litellm_params=litellm_params + ) + expected = {"Authorization": "Bearer test-azure-ad-token"} + + assert result == expected diff --git a/tests/test_litellm/llms/azure/test_azure_common_utils.py b/tests/test_litellm/llms/azure/test_azure_common_utils.py index f304ce43923..c3eab6e1578 100644 --- a/tests/test_litellm/llms/azure/test_azure_common_utils.py +++ b/tests/test_litellm/llms/azure/test_azure_common_utils.py @@ -11,8 +11,9 @@ sys.path.insert( 0, os.path.abspath("../../../..") ) # Adds the parent directory to the system path import litellm -from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.azure.common_utils import BaseAzureLLM, get_azure_ad_token from litellm.types.utils import CallTypes +from litellm.types.router import GenericLiteLLMParams # Mock the necessary dependencies @@ -994,3 +995,363 @@ def test_scope_always_string_in_initialize_azure_sdk_client(setup_mocks, monkeyp ) print("All scope tests passed - scope is always a string") + + +def test_with_existing_token_provider(setup_mocks): + """Test get_azure_ad_token with an existing token provider.""" + token_provider = lambda: "test-token" + litellm_params = GenericLiteLLMParams(azure_ad_token_provider=token_provider) + + token = get_azure_ad_token(litellm_params) + + assert token == "test-token" + + +def test_with_existing_azure_ad_token(setup_mocks): + """Test get_azure_ad_token with an existing azure ad token.""" + litellm_params = GenericLiteLLMParams(azure_ad_token="test-token") + + token = get_azure_ad_token(litellm_params) + + assert token == "test-token" + + +def test_with_existing_azure_ad_token_from_env(setup_mocks): + """Test get_azure_ad_token with an existing AZURE_AD_TOKEN from env.""" + + # mock get_secret_str("AZURE_AD_TOKEN") to "test-token" + with patch("litellm.llms.azure.common_utils.get_secret_str") as mock_get_secret_str: + # Configure the mock to return "test-token" when called with "AZURE_AD_TOKEN" + mock_get_secret_str.side_effect = ( + lambda key: "test-token" if key == "AZURE_AD_TOKEN" else None + ) + + litellm_params = GenericLiteLLMParams() + + token = get_azure_ad_token(litellm_params) + + assert token == "test-token" + # Verify that get_secret_str was called with "AZURE_AD_TOKEN" + mock_get_secret_str.assert_called_with("AZURE_AD_TOKEN") + + +def test_get_azure_ad_token_with_client_id_and_client_secret(setup_mocks): + """Test get_azure_ad_token with tenant_id, client_id, and client_secret.""" + # Reset mocks to ensure clean state + setup_mocks["entra_token"].reset_mock() + + # Create test parameters with username, password, and client_id + # but no other authentication methods + litellm_params = GenericLiteLLMParams( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + azure_scope="test-azure-scope", + ) + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure AD Token Provider from Entra ID for Azure Auth" + ) + + # Verify get_azure_ad_token_from_entra_id was called with correct params + setup_mocks["entra_token"].assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + scope="test-azure-scope", + ) + + # Verify the token is what we expect from our mock + assert token == "mock-entra-token" + + +def test_get_azure_ad_token_with_client_id_and_client_secret_from_env( + setup_mocks, monkeypatch +): + """Test get_azure_ad_token with tenant_id, client_id, and client_secret from env.""" + # Reset mocks to ensure clean state + setup_mocks["entra_token"].reset_mock() + + # Set environment variables + monkeypatch.setenv("AZURE_TENANT_ID", "test-tenant-id") + monkeypatch.setenv("AZURE_CLIENT_ID", "test-client-id") + monkeypatch.setenv("AZURE_CLIENT_SECRET", "test-client-secret") + monkeypatch.setenv("AZURE_SCOPE", "test-azure-scope") + + # Create test parameters with username, password, and client_id + # but no other authentication methods + litellm_params = GenericLiteLLMParams() + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure AD Token Provider from Entra ID for Azure Auth" + ) + + # Verify get_azure_ad_token_from_entra_id was called with correct params + setup_mocks["entra_token"].assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + scope="test-azure-scope", + ) + + # Verify the token is what we expect from our mock + assert token == "mock-entra-token" + + +def test_get_azure_ad_token_with_username_password(setup_mocks): + """Test get_azure_ad_token with username, password, and client_id.""" + # Reset mocks to ensure clean state + setup_mocks["username_password_token"].reset_mock() + + # Create test parameters with username, password, and client_id + # but no other authentication methods + litellm_params = GenericLiteLLMParams( + azure_username="test-username", + azure_password="test-password", + client_id="test-client-id", + azure_scope="test-azure-scope", + # Ensure no other auth methods are available + azure_ad_token_provider=None, + azure_ad_token=None, + tenant_id=None, + client_secret=None, + ) + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure Username and Password for Azure Auth" + ) + + # Verify get_azure_ad_token_from_username_password was called with correct params + setup_mocks["username_password_token"].assert_called_once_with( + azure_username="test-username", + azure_password="test-password", + client_id="test-client-id", + scope="test-azure-scope", + ) + + # Verify the token is what we expect from our mock + assert token == "mock-username-password-token" + + +def test_get_azure_ad_token_with_missing_username_password(setup_mocks): + """Test get_azure_ad_token skips username/password auth when credentials are incomplete.""" + # Reset mocks to ensure clean state + setup_mocks["username_password_token"].reset_mock() + + # Test cases with missing credentials + test_cases = [ + # Missing username + GenericLiteLLMParams( + azure_username=None, + azure_password="test-password", + client_id="test-client-id", + ), + # Missing password + GenericLiteLLMParams( + azure_username="test-username", + azure_password=None, + client_id="test-client-id", + ), + # Missing client_id + GenericLiteLLMParams( + azure_username="test-username", + azure_password="test-password", + client_id=None, + ), + ] + + for params in test_cases: + # Call the function + get_azure_ad_token(params) + + # Verify username/password auth was not used + setup_mocks["username_password_token"].assert_not_called() + + # Reset mock for next test case + setup_mocks["username_password_token"].reset_mock() + + +def test_get_azure_ad_token_with_username_password_from_env(setup_mocks, monkeypatch): + """Test get_azure_ad_token with username, password, and client_id from environment variables.""" + # Reset mocks to ensure clean state + setup_mocks["username_password_token"].reset_mock() + + # Set environment variables + monkeypatch.setenv("AZURE_USERNAME", "env-username") + monkeypatch.setenv("AZURE_PASSWORD", "env-password") + monkeypatch.setenv("AZURE_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AZURE_SCOPE", "test-azure-scope") + + # Create test parameters with no explicit credentials + litellm_params = GenericLiteLLMParams( + # Ensure no other auth methods are available + azure_ad_token_provider=None, + azure_ad_token=None, + tenant_id=None, + client_secret=None, + # Don't set username, password, or client_id directly + ) + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure Username and Password for Azure Auth" + ) + + # Verify get_azure_ad_token_from_username_password was called with correct params from env + setup_mocks["username_password_token"].assert_called_once_with( + azure_username="env-username", + azure_password="env-password", + client_id="env-client-id", + scope="test-azure-scope", + ) + + # Verify the token is what we expect from our mock + assert token == "mock-username-password-token" + + +def test_get_azure_ad_token_with_oidc_token(setup_mocks): + """Test get_azure_ad_token with OIDC token.""" + # Reset mocks to ensure clean state + setup_mocks["oidc_token"].reset_mock() + + # Create test parameters with OIDC token, client_id, and tenant_id + litellm_params = GenericLiteLLMParams( + azure_ad_token="oidc/test-token", + client_id="test-client-id", + tenant_id="test-tenant-id", + azure_scope="test-azure-scope", + # Ensure no other auth methods are available + azure_ad_token_provider=None, + ) + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call("Using Azure OIDC Token for Azure Auth") + + # Verify get_azure_ad_token_from_oidc was called with correct params + setup_mocks["oidc_token"].assert_called_once_with( + azure_ad_token="oidc/test-token", + azure_client_id="test-client-id", + azure_tenant_id="test-tenant-id", + scope="test-azure-scope", + ) + + # Verify the token is what we expect from our mock + assert token == "mock-oidc-token" + + +def test_get_azure_ad_token_with_token_refresh(setup_mocks): + """Test get_azure_ad_token with token refresh enabled.""" + # Reset mocks to ensure clean state + setup_mocks["token_provider"].reset_mock() + + # Enable token refresh + setup_mocks["litellm"].enable_azure_ad_token_refresh = True + + # Create test parameters with no other auth methods available + litellm_params = GenericLiteLLMParams() + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure AD token provider based on Service Principal with Secret workflow for Azure Auth" + ) + + # Verify get_azure_ad_token_provider was called + setup_mocks["token_provider"].assert_called_once() + + # Verify the token is what we expect from our mock + assert token == "mock-default-token" + + +def test_get_azure_ad_token_with_token_refresh_error(setup_mocks): + """Test get_azure_ad_token with token refresh enabled but raising an error.""" + # Reset mocks to ensure clean state + setup_mocks["token_provider"].reset_mock() + + # Enable token refresh but make it raise an error + setup_mocks["litellm"].enable_azure_ad_token_refresh = True + setup_mocks["token_provider"].side_effect = ValueError("Token provider error") + + # Create test parameters with no other auth methods available + litellm_params = GenericLiteLLMParams() + + # Call the function + token = get_azure_ad_token(litellm_params) + + # Verify the debug message was logged + setup_mocks["logger"].debug.assert_any_call( + "Using Azure AD token provider based on Service Principal with Secret workflow for Azure Auth" + ) + + # Verify error was logged + setup_mocks["logger"].debug.assert_any_call( + "Azure AD Token Provider could not be used." + ) + + # Verify get_azure_ad_token_provider was called + setup_mocks["token_provider"].assert_called_once() + + # Verify the token is None since the provider raised an error + assert token is None + + +def test_token_provider_returns_non_string(setup_mocks): + """Test that get_azure_ad_token raises TypeError when token provider returns non-string value.""" + # Create a token provider that returns a non-string value + non_string_provider = lambda: 123 # Returns an integer instead of a string + + # Create test parameters with the non-string token provider + litellm_params = GenericLiteLLMParams(azure_ad_token_provider=non_string_provider) + + # Call the function and expect a TypeError + with pytest.raises(TypeError) as excinfo: + get_azure_ad_token(litellm_params) + + # Verify the error message + assert "Azure AD token must be a string" in str(excinfo.value) + + # Verify the error was logged + setup_mocks["logger"].error.assert_any_call( + "Azure AD token provider returned non-string value: " + ) + + +def test_token_provider_raises_exception(setup_mocks): + """Test that get_azure_ad_token raises RuntimeError when token provider raises an exception.""" + # Create a token provider that raises an exception + error_message = "Test provider error" + error_provider = lambda: exec('raise ValueError("' + error_message + '")') + + # Create test parameters with the error-raising token provider + litellm_params = GenericLiteLLMParams(azure_ad_token_provider=error_provider) + + # Call the function and expect a RuntimeError + with pytest.raises(RuntimeError) as excinfo: + get_azure_ad_token(litellm_params) + + # Verify the error message + assert "Failed to get Azure AD token" in str(excinfo.value) + assert error_message in str(excinfo.value) + + # Verify the error was logged + setup_mocks["logger"].error.assert_called() diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index a926c33f53b..db0a69482b2 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -153,9 +153,9 @@ class TestOpenAIResponsesAPIConfig: # Test with provided API key headers = {} api_key = "test_api_key" - + litellm_params = GenericLiteLLMParams(api_key=api_key) result = self.config.validate_environment( - headers=headers, model=self.model, api_key=api_key + headers=headers, model=self.model, litellm_params=litellm_params ) assert "Authorization" in result @@ -165,7 +165,10 @@ class TestOpenAIResponsesAPIConfig: headers = {} with patch("litellm.api_key", "litellm_api_key"): - result = self.config.validate_environment(headers=headers, model=self.model) + litellm_params = GenericLiteLLMParams() + result = self.config.validate_environment( + headers=headers, model=self.model, litellm_params=litellm_params + ) assert "Authorization" in result assert result["Authorization"] == "Bearer litellm_api_key" @@ -175,8 +178,9 @@ class TestOpenAIResponsesAPIConfig: with patch("litellm.openai_key", "openai_key"): with patch("litellm.api_key", None): + litellm_params = GenericLiteLLMParams() result = self.config.validate_environment( - headers=headers, model=self.model + headers=headers, model=self.model, litellm_params=litellm_params ) assert "Authorization" in result @@ -193,8 +197,9 @@ class TestOpenAIResponsesAPIConfig: "litellm.llms.openai.responses.transformation.get_secret_str", return_value="env_api_key", ): + litellm_params = GenericLiteLLMParams() result = self.config.validate_environment( - headers=headers, model=self.model + headers=headers, model=self.model, litellm_params=litellm_params ) assert "Authorization" in result From 464d981f1c95decddc436ee42ce80beec30eb295 Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:22:00 -0600 Subject: [PATCH 04/45] feat: add Last Success column to health check table (#11903) --- .../model_dashboard/HealthCheckComponent.tsx | 5 +++ .../model_dashboard/health_check_columns.tsx | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index aac92e98c12..834a1c834f8 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -261,6 +261,7 @@ const HealthCheckComponent: React.FC = ({ [modelName]: { status: 'unhealthy', lastCheck: currentTime, + lastSuccess: prev[modelName]?.lastSuccess || 'None', loading: false, error: errorMessage, fullError: rawError @@ -319,6 +320,7 @@ const HealthCheckComponent: React.FC = ({ [modelName]: { status: 'unhealthy', lastCheck: currentTime, + lastSuccess: prev[modelName]?.lastSuccess || 'None', loading: false, error: errorMessage, fullError: rawError @@ -365,6 +367,7 @@ const HealthCheckComponent: React.FC = ({ [modelName]: { status: 'unhealthy', lastCheck: currentTime, + lastSuccess: prev[modelName]?.lastSuccess || 'None', loading: false, error: errorMessage, fullError: rawError @@ -393,6 +396,7 @@ const HealthCheckComponent: React.FC = ({ [modelName]: { status: 'unhealthy', lastCheck: currentTime, + lastSuccess: prev[modelName]?.lastSuccess || 'None', loading: false, error: errorMessage, fullError: rawError @@ -562,6 +566,7 @@ const HealthCheckComponent: React.FC = ({ litellm_model_name: model.litellm_model_name, health_status: healthStatus.status, last_check: healthStatus.lastCheck, + last_success: healthStatus.lastSuccess || 'None', health_loading: healthStatus.loading, health_error: healthStatus.error, health_full_error: healthStatus.fullError, diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx index 14cb200858a..2e70105a241 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx @@ -15,6 +15,7 @@ interface HealthCheckData { litellm_model_name?: string; health_status: string; last_check: string; + last_success: string; health_loading: boolean; health_error?: string; health_full_error?: string; @@ -235,6 +236,47 @@ export const healthCheckColumns = ( ); }, }, + { + header: "Last Success", + accessorKey: "last_success", + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const lastSuccessA = rowA.getValue("last_success") as string || 'Never succeeded'; + const lastSuccessB = rowB.getValue("last_success") as string || 'Never succeeded'; + + // Handle special cases + if (lastSuccessA === 'Never succeeded' && lastSuccessB === 'Never succeeded') return 0; + if (lastSuccessA === 'Never succeeded') return 1; // Never succeeded goes to bottom + if (lastSuccessB === 'Never succeeded') return -1; + if (lastSuccessA === 'None' && lastSuccessB === 'None') return 0; + if (lastSuccessA === 'None') return 1; // None goes to bottom + if (lastSuccessB === 'None') return -1; + + // Parse dates for comparison + const dateA = new Date(lastSuccessA); + const dateB = new Date(lastSuccessB); + + // If dates are invalid, treat as never succeeded + if (isNaN(dateA.getTime()) && isNaN(dateB.getTime())) return 0; + if (isNaN(dateA.getTime())) return 1; + if (isNaN(dateB.getTime())) return -1; + + // Sort by date (most recent first) + return dateB.getTime() - dateA.getTime(); + }, + cell: ({ row }) => { + const model = row.original; + const modelName = model.model_name; + const healthStatus = modelHealthStatuses[modelName]; + const lastSuccess = healthStatus?.lastSuccess || 'None'; + + return ( + + {lastSuccess} + + ); + }, + }, { header: "Actions", id: "actions", From 3ab1dfad08ce62254b49017825dd6004e5f6af33 Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:23:27 -0600 Subject: [PATCH 05/45] Add GitHub Actions workflow for LLM translation testing artifacts (#11780) * Add GitHub Actions workflow for LLM translation testing artifacts * Update LLM translation testing workflow to fetch results from CircleCI and improve timeout settings. The job name has been changed for clarity, and the installation of dependencies has been replaced with CircleCI CLI setup. Placeholder test results are created if no CircleCI artifacts are found. --- .github/workflows/llm-translation-testing.yml | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/llm-translation-testing.yml diff --git a/.github/workflows/llm-translation-testing.yml b/.github/workflows/llm-translation-testing.yml new file mode 100644 index 00000000000..83566d6eb6a --- /dev/null +++ b/.github/workflows/llm-translation-testing.yml @@ -0,0 +1,157 @@ +name: LLM Translation Test Results for Release Candidates + +on: + workflow_dispatch: + inputs: + release_candidate_tag: + description: 'Release candidate tag/version' + required: true + type: string + push: + tags: + - 'v*-rc*' # Triggers on release candidate tags like v1.0.0-rc1 + +jobs: + fetch-llm-translation-results: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_candidate_tag || github.ref }} + + - name: Install CircleCI CLI + run: | + curl -fLSs https://raw.githubusercontent.com/CircleCI-Public/circleci-cli/main/install.sh | sudo bash + + - name: Create test results directory + run: mkdir -p test-results + + - name: Fetch LLM Translation Test Results from CircleCI + env: + CIRCLE_TOKEN: ${{ secrets.CIRCLE_TOKEN }} + run: | + # Get the latest CircleCI pipeline for this commit + COMMIT_SHA="${{ github.sha }}" + echo "Fetching CircleCI results for commit: $COMMIT_SHA" + + # Get pipeline info + PIPELINE_INFO=$(curl -s -H "Circle-Token: $CIRCLE_TOKEN" \ + "https://circleci.com/api/v2/project/github/BerriAI/litellm/pipeline?branch=main" | \ + jq -r ".items[] | select(.vcs.revision == \"$COMMIT_SHA\") | .id" | head -1) + + if [ -z "$PIPELINE_INFO" ]; then + echo "No CircleCI pipeline found for commit $COMMIT_SHA" + echo "Creating placeholder test results..." + echo '' > test-results/junit.xml + echo '' >> test-results/junit.xml + echo 'No CircleCI results found for this commit' >> test-results/junit.xml + echo '' >> test-results/junit.xml + exit 0 + fi + + echo "Found pipeline: $PIPELINE_INFO" + + # Get workflow info + WORKFLOW_ID=$(curl -s -H "Circle-Token: $CIRCLE_TOKEN" \ + "https://circleci.com/api/v2/pipeline/$PIPELINE_INFO/workflow" | \ + jq -r '.items[] | select(.name | contains("test")) | .id' | head -1) + + if [ -z "$WORKFLOW_ID" ]; then + echo "No test workflow found in pipeline" + exit 1 + fi + + echo "Found workflow: $WORKFLOW_ID" + + # Get job info for llm_translation tests + JOB_INFO=$(curl -s -H "Circle-Token: $CIRCLE_TOKEN" \ + "https://circleci.com/api/v2/workflow/$WORKFLOW_ID/job" | \ + jq -r '.items[] | select(.name | contains("llm_translation")) | select(.status == "success" or .status == "failed") | .job_number' | head -1) + + if [ -z "$JOB_INFO" ]; then + echo "No completed llm_translation job found" + exit 1 + fi + + echo "Found job: $JOB_INFO" + + # Download artifacts + curl -s -H "Circle-Token: $CIRCLE_TOKEN" \ + "https://circleci.com/api/v2/project/github/BerriAI/litellm/$JOB_INFO/artifacts" | \ + jq -r '.items[] | select(.path | contains("junit") or contains("coverage") or contains("report")) | .url' | \ + while read -r artifact_url; do + filename=$(basename "$artifact_url" | sed 's/[?&].*//') + echo "Downloading artifact: $filename" + curl -s -H "Circle-Token: $CIRCLE_TOKEN" -o "test-results/$filename" "$artifact_url" + done + + # If no artifacts found, create placeholder + if [ ! -f "test-results/junit.xml" ]; then + echo "No test artifacts found, creating placeholder..." + echo '' > test-results/junit.xml + echo '' >> test-results/junit.xml + echo 'Test artifacts not available from CircleCI' >> test-results/junit.xml + echo '' >> test-results/junit.xml + fi + continue-on-error: true + + - name: Generate test summary + run: | + echo "# LLM Translation Testing Results" > test-results/summary.md + echo "" >> test-results/summary.md + echo "**Release Candidate:** ${{ github.event.inputs.release_candidate_tag || github.ref_name }}" >> test-results/summary.md + echo "**Fetched Date:** $(date)" >> test-results/summary.md + echo "**Commit:** ${{ github.sha }}" >> test-results/summary.md + echo "**Source:** CircleCI Pipeline" >> test-results/summary.md + echo "" >> test-results/summary.md + + # Parse junit.xml for test statistics if it exists + if [ -f "test-results/junit.xml" ]; then + python -c " + import xml.etree.ElementTree as ET + try: + tree = ET.parse('test-results/junit.xml') + root = tree.getroot() + tests = root.get('tests', '0') + failures = root.get('failures', '0') + errors = root.get('errors', '0') + skipped = root.get('skipped', '0') + time = root.get('time', '0') + + print(f'**Total Tests:** {tests}') + print(f'**Passed:** {int(tests) - int(failures) - int(errors) - int(skipped)}') + print(f'**Failed:** {failures}') + print(f'**Errors:** {errors}') + print(f'**Skipped:** {skipped}') + print(f'**Duration:** {time} seconds') + except Exception as e: + print(f'Could not parse test results: {e}') + " >> test-results/summary.md + fi + + echo "" >> test-results/summary.md + echo "## Test Files Covered" >> test-results/summary.md + ls tests/llm_translation/*.py | sed 's/^/- /' >> test-results/summary.md + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: llm-translation-test-results-${{ github.event.inputs.release_candidate_tag || github.ref_name }} + path: | + test-results/ + coverage.xml + htmlcov/ + .coverage + retention-days: 30 + + - name: Upload JUnit test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-xml-${{ github.event.inputs.release_candidate_tag || github.ref_name }} + path: test-results/junit.xml + retention-days: 30 \ No newline at end of file From a44ff7c486b9ddc6595a969f016b58f84bb68993 Mon Sep 17 00:00:00 2001 From: Mukesh Sai Kumar Date: Mon, 23 Jun 2025 21:58:51 +0530 Subject: [PATCH 06/45] Fix markdown table not rendering properly (#11969) --- docs/my-website/docs/completion/input.md | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/my-website/docs/completion/input.md b/docs/my-website/docs/completion/input.md index fb0fc390ad0..b08941cde92 100644 --- a/docs/my-website/docs/completion/input.md +++ b/docs/my-website/docs/completion/input.md @@ -39,31 +39,32 @@ This is a list of openai params we translate across providers. Use `litellm.get_supported_openai_params()` for an updated list of params for each model + provider -| Provider | temperature | max_completion_tokens | max_tokens | top_p | stream | stream_options | stop | n | presence_penalty | frequency_penalty | functions | function_call | logit_bias | user | response_format | seed | tools | tool_choice | logprobs | top_logprobs | extra_headers | -|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| -|Anthropic| ✅ | ✅ | ✅ |✅ | ✅ | ✅ | ✅ | | | | | | |✅ | ✅ | | ✅ | ✅ | | | ✅ | -|OpenAI| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |✅ | ✅ | ✅ | ✅ |✅ | ✅ | ✅ | ✅ | ✅ | -|Azure OpenAI| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |✅ | ✅ | ✅ | ✅ |✅ | ✅ | ✅ | ✅ | ✅ | -|xAI| ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | -|Replicate | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | -|Anyscale | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -|Cohere| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | -|Huggingface| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | -|Openrouter| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | ✅ |✅ | | | | -|AI21| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | -|VertexAI| ✅ | ✅ | ✅ | | ✅ | ✅ | | | | | | | | | ✅ | ✅ | | | -|Bedrock| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | ✅ (model dependent) | | -|Sagemaker| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | -|TogetherAI| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ | | | ✅ | | ✅ | ✅ | | | | -|Sambanova| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | ✅ | | ✅ | ✅ | | | | -|AlephAlpha| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | -|NLP Cloud| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | -|Petals| ✅ | ✅ | | ✅ | ✅ | | | | | | -|Ollama| ✅ | ✅ | ✅ |✅ | ✅ | ✅ | | | ✅ | | | | | ✅ | | |✅| | | | | | | -|Databricks| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | | -|ClarifAI| ✅ | ✅ | ✅ | |✅ | ✅ | | | | | | | | | | | -|Github| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | ✅ |✅ (model dependent)|✅ (model dependent)| | | -|Novita AI| ✅ | ✅ | | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | | ✅ | | | | | | | | +| Provider | temperature | max_completion_tokens | max_tokens | top_p | stream | stream_options | stop | n | presence_penalty | frequency_penalty | functions | function_call | logit_bias | user | response_format | seed| tools | tool_choice | logprobs | top_logprobs | extra_headers | +|--------------|-------------|------------------------|------------|-------|--------|----------------|------|-----|------------------|-------------------|-----------|----------------|-------------|------|------------------|-------------------|--------|--------------|----------|---------------|----------------------| +| Anthropic| ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | || | || | ✅ | ✅ | | ✅ | ✅ || | ✅| +| OpenAI | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| ✅| ✅ | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅| +| Azure OpenAI | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| ✅| ✅ | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅| +| xAI| ✅|| ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| || ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅|| +| Replicate| ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | || ||| |||| || +| Anyscale | ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | || ||| |||| || +| Cohere | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅|| | || ||| |||| || +| Huggingface| ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | || | || ||| |||| || +| Openrouter | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| ✅|| ||| ✅| ✅ ||| || +| AI21 | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅|| | || ||| |||| || +| VertexAI | ✅| ✅ | ✅ | | ✅ | ✅ || || | || || ✅ | ✅|||| || +| Bedrock| ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | || || ✅ (model dependent) | |||| || +| Sagemaker| ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | || | || ||| |||| || +| TogetherAI | ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | ✅|| || ✅ | | ✅ | ✅ || || +| Sambanova| ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | || | || || ✅ | | ✅ | ✅ || || +| AlephAlpha | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | || | || ||| |||| || +| NLP Cloud| ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | || ||| |||| || +| Petals | ✅| ✅ || ✅| ✅ ||| || | || ||| |||| || +| Ollama | ✅| ✅ | ✅ | ✅| ✅ | ✅ || ✅|| | || ✅||| | ✅ ||| || +| Databricks | ✅| ✅ | ✅ | ✅| ✅ | ✅ || || | || ||| |||| || +| ClarifAI | ✅| ✅ | ✅ | | ✅ | ✅ || || | || ||| |||| || +| Github | ✅| ✅ | ✅ | ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| ✅|| || ✅ | ✅ (model dependent) | ✅ (model dependent) || || +| Novita AI| ✅| ✅ || ✅| ✅ | ✅ | ✅ | ✅| ✅ | ✅| || ✅||| |||| || + :::note By default, LiteLLM raises an exception if the openai param being passed in isn't supported. From cde20cf825072a373611a70cdff107853b087ce2 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 09:30:40 -0700 Subject: [PATCH 07/45] fix - checking proxy settings (#11947) --- litellm/__init__.py | 1 + .../llms/custom_httpx/aiohttp_transport.py | 36 +++++++++++++++- .../custom_httpx/test_aiohttp_transport.py | 43 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 614d6de8e7d..5407dd85d58 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -324,6 +324,7 @@ use_aiohttp_transport: bool = ( True # Older variable, aiohttp is now the default. use disable_aiohttp_transport instead. ) disable_aiohttp_transport: bool = False # Set this to true to use httpx instead +disable_aiohttp_trust_env: bool = False # When False, aiohttp will respect HTTP(S)_PROXY env vars force_ipv4: bool = ( False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. ) diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index 279cf2e9f45..3ed7d04bde6 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -1,6 +1,8 @@ import asyncio import contextlib +import os import typing +import urllib.request from typing import Callable, Dict, Union import aiohttp @@ -9,7 +11,9 @@ import aiohttp.http_exceptions import httpx from aiohttp.client import ClientResponse, ClientSession +import litellm from litellm._logging import verbose_logger +from litellm.secret_managers.main import str_to_bool AIOHTTP_EXC_MAP: Dict = { # Order matters here, most specific exception first @@ -182,7 +186,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): self.client = ClientSession() return self.client - + async def handle_async_request( self, request: httpx.Request, @@ -196,6 +200,9 @@ class LiteLLMAiohttpTransport(AiohttpTransport): # Use helper to ensure we have a valid session for the current event loop client_session = self._get_valid_client_session() + # Resolve proxy settings from environment variables + proxy = await self._get_proxy_settings(request) + with map_aiohttp_exceptions(): try: data = request.content @@ -215,6 +222,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): sock_read=timeout.get("read"), connect=timeout.get("pool"), ), + proxy=proxy, server_hostname=sni_hostname, ).__aenter__() @@ -224,3 +232,29 @@ class LiteLLMAiohttpTransport(AiohttpTransport): content=AiohttpResponseStream(response), request=request, ) + + + async def _get_proxy_settings(self, request: httpx.Request): + proxy = None + if not ( + litellm.disable_aiohttp_trust_env + or str_to_bool(os.getenv("DISABLE_AIOHTTP_TRUST_ENV", "False")) + ): + try: + proxy = self._proxy_from_env(request.url) + except Exception as e: # pragma: no cover - best effort + verbose_logger.debug(f"Error reading proxy env: {e}") + + return proxy + + + def _proxy_from_env(self, url: httpx.URL) -> typing.Optional[str]: + """Return proxy URL from env for the given request URL.""" + proxies = urllib.request.getproxies() + if urllib.request.proxy_bypass(url.host): + return None + + proxy = proxies.get(url.scheme) or proxies.get("all") + if proxy and "://" not in proxy: + proxy = f"http://{proxy}" + return proxy diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 4d1023d873f..f296f076178 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -176,3 +176,46 @@ async def test_timeout_exception_gets_mapped(): # Should have received the first chunk before the error assert received_chunks == [b"chunk1"] + + +@pytest.mark.asyncio +async def test_handle_async_request_uses_env_proxy(monkeypatch): + """Aiohttp transport should honor HTTP(S)_PROXY env vars""" + proxy_url = "http://proxy.local:3128" + monkeypatch.setenv("HTTP_PROXY", proxy_url) + monkeypatch.setenv("http_proxy", proxy_url) + monkeypatch.setenv("HTTPS_PROXY", proxy_url) + monkeypatch.setenv("https_proxy", proxy_url) + monkeypatch.delenv("DISABLE_AIOHTTP_TRUST_ENV", raising=False) + + captured = {} + + class FakeSession: + def request(self, *args, **kwargs): + captured["proxy"] = kwargs.get("proxy") + + class Resp: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + @property + def content(self): + class C: + async def iter_chunked(self, size): + yield b"" + + return C() + + return Resp() + + transport = LiteLLMAiohttpTransport(client=lambda: FakeSession()) + request = httpx.Request("GET", "http://example.com") + await transport.handle_async_request(request) + + assert captured["proxy"] == proxy_url From b3d8a6be9ed8ff1371100185d7474354b298e268 Mon Sep 17 00:00:00 2001 From: Tyler Wagner Date: Mon, 23 Jun 2025 12:34:37 -0400 Subject: [PATCH 08/45] Proxy UI MCP Auth passthrough (#11968) * initial mcp auth with special header * MCP Servers moved and added auth part * Pass in x-mcp-auth with banner on tool call * Revert "initial mcp auth with special header" This reverts commit b22fb6d12e9af17701ab5ec58ab559663073416b. --------- Co-authored-by: wagnerjt --- .../src/components/mcp_tools/columns.tsx | 39 ++- .../src/components/mcp_tools/index.tsx | 176 +--------- .../components/mcp_tools/mcp_server_view.tsx | 1 + .../src/components/mcp_tools/mcp_tools.tsx | 306 +++++++++++++++++- .../src/components/mcp_tools/types.tsx | 15 +- .../src/components/networking.tsx | 8 +- 6 files changed, 364 insertions(+), 181 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx index 72c326da565..701aef04fad 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx @@ -1,7 +1,34 @@ import React from "react"; import { ColumnDef } from "@tanstack/react-table"; import { MCPTool, InputSchema } from "./types"; -import { Button } from "@tremor/react" +import { Button, Callout, Icon } from "@tremor/react" + +const AuthBanner = ({needsAuth, authValue}) => { + if(!needsAuth || (needsAuth && authValue)) { + return ( + + This tool does not require authentication or has authentication added. + + ) + } + + if (needsAuth && !authValue) { + return ( + + Please provide authentication details if this tool call requires auth. + + ); + } + return null; +} export const columns: ColumnDef[] = [ { @@ -78,6 +105,8 @@ export const columns: ColumnDef[] = [ // Tool Panel component to display when a tool is selected export function ToolTestPanel({ tool, + needsAuth, + authValue, onSubmit, isLoading, result, @@ -85,6 +114,8 @@ export function ToolTestPanel({ onClose }: { tool: MCPTool; + needsAuth: boolean; + authValue?: string | null; onSubmit: (args: Record) => void; isLoading: boolean; result: any | null; @@ -131,6 +162,12 @@ export function ToolTestPanel({

{tool.description}

Provider: {tool.mcp_info.server_name}

+
+ +
+ + + ); +}; + +// Wrapper to handle the type mismatch between MCPTool and DataTable's expected type +function DataTableWrapper({ + columns, + data, + isLoading, +}: { + columns: any; + data: MCPTool[]; + isLoading: boolean; +}) { + // Create a dummy renderSubComponent and getRowCanExpand function + const renderSubComponent = () =>
; + const getRowCanExpand = () => false; + + return ( + + ); +} + +const MCPToolsViewer = ({ + serverId, + accessToken, + auth_type, + userRole, + userID, +}: MCPToolsViewerProps) => { + const [searchTerm, setSearchTerm] = useState(""); + const [mcpAuthValue, setMcpAuthValue] = useState(""); + const [selectedTool, setSelectedTool] = useState(null); + const [toolResult, setToolResult] = useState( + null + ); + const [toolError, setToolError] = useState(null); + + // Query to fetch MCP tools + const { data: mcpTools, isLoading: isLoadingTools } = useQuery({ + queryKey: ["mcpTools"], + queryFn: () => { + if (!accessToken) throw new Error("Access Token required"); + return listMCPTools(accessToken, serverId); + }, + enabled: !!accessToken, + }); + + // Mutation for calling a tool + const { mutate: executeTool, isPending: isCallingTool } = useMutation({ + mutationFn: (args: { tool: MCPTool; arguments: Record, authValue: string }) => { + if (!accessToken) throw new Error("Access Token required"); + return callMCPTool(accessToken, args.tool.name, args.arguments, args.authValue); + }, + onSuccess: (data) => { + setToolResult(data); + setToolError(null); + }, + onError: (error: Error) => { + setToolError(error); + setToolResult(null); + }, + }); + + // Add onToolSelect handler to each tool + const toolsData = React.useMemo(() => { + if (!mcpTools) return []; + + return mcpTools.map((tool: MCPTool) => ({ + ...tool, + onToolSelect: (tool: MCPTool) => { + setSelectedTool(tool); + setToolResult(null); + setToolError(null); + }, + })); + }, [mcpTools]); + + // Filter tools based on search term + const filteredTools = React.useMemo(() => { + return toolsData.filter((tool: MCPTool) => { + const searchLower = searchTerm.toLowerCase(); + return ( + tool.name.toLowerCase().includes(searchLower) || + (tool.description != null && + tool.description.toLowerCase().includes(searchLower)) || + tool.mcp_info.server_name.toLowerCase().includes(searchLower) + ); + }); + }, [toolsData, searchTerm]); + + // Handle tool call submission + const handleToolSubmit = (args: Record) => { + if (!selectedTool) return; + + executeTool({ + tool: selectedTool, + arguments: args, + authValue: mcpAuthValue + }); + }; + + if (!accessToken || !userRole || !userID) { + return ( +
+ Missing required authentication parameters. +
+ ); + } + + return ( +
+
+

MCP Tools

+
+ + {mcpServerHasAuth(auth_type) && ( + { + setMcpAuthValue(value); + }} + /> + )} + +
+
+
+
+ setSearchTerm(e.target.value)} + /> + + + +
+
+ {filteredTools.length} tool{filteredTools.length !== 1 ? "s" : ""}{" "} + available +
+
+
+ + +
+ + {/* Tool Test Panel - Show when a tool is selected */} + {selectedTool && ( +
+ setSelectedTool(null)} + /> +
+ )} +
+ ); +}; -// TODO: Move Tools viewer from index file to this file export default MCPToolsViewer; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index de85778701d..40ea23007ab 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -1,3 +1,11 @@ +// Default no auth value +export const AUTH_TYPE = { + NONE: "none", + API_KEY: "api_key", + BEARER_TOKEN: "bearer_token", + BASIC: "basic", +}; + export const TRANSPORT = { SSE: "sse", HTTP: "http", @@ -14,12 +22,16 @@ export const handleTransport = (transport?: string | null): string => { export const handleAuth = (authType?: string | null): string => { if (authType === null || authType === undefined) { - return "none"; + return AUTH_TYPE.NONE; } return authType; }; +export const mcpServerHasAuth = (authType?: string | null): boolean => { + return handleAuth(authType) !== AUTH_TYPE.NONE; +} + // Define the structure for tool input schema properties export interface InputSchemaProperty { type: string; @@ -89,6 +101,7 @@ export interface InputSchemaProperty { export interface MCPToolsViewerProps { serverId: string; accessToken: string | null; + auth_type?: string | null; userRole: string | null; userID: string | null; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d51d83b7bf6..2ec8642458d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -136,7 +136,8 @@ const handleError = async (errorData: string) => { }; // Global variable for the header name -let globalLitellmHeaderName: string = "Authorization"; +let globalLitellmHeaderName: string = "Authorization"; +const MCP_AUTH_HEADER: string = "x-mcp-auth"; // Function to set the global header name export function setGlobalLitellmHeaderName( @@ -4882,10 +4883,12 @@ export const listMCPTools = async (accessToken: string, serverId: string) => { } }; + export const callMCPTool = async ( accessToken: string, toolName: string, - toolArguments: Record + toolArguments: Record, + authValue: string, ) => { try { // Construct base URL @@ -4904,6 +4907,7 @@ export const callMCPTool = async ( method: "POST", headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}`, + [MCP_AUTH_HEADER]: authValue, "Content-Type": "application/json", }, body: JSON.stringify({ From 30318c40c9889f48b01869345e9e26868ce64ffb Mon Sep 17 00:00:00 2001 From: Shankyg Date: Tue, 24 Jun 2025 02:41:40 +0930 Subject: [PATCH 09/45] fix unrecognised parameter reasoning_effort (#11838) * fix unrecognised parameter reasoning_effort * review changes --- litellm/llms/azure/chat/o_series_transformation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/llms/azure/chat/o_series_transformation.py b/litellm/llms/azure/chat/o_series_transformation.py index 69fb941ca58..767f2d46df3 100644 --- a/litellm/llms/azure/chat/o_series_transformation.py +++ b/litellm/llms/azure/chat/o_series_transformation.py @@ -17,7 +17,7 @@ from typing import List, Optional import litellm from litellm import verbose_logger from litellm.types.llms.openai import AllMessageValues -from litellm.utils import get_model_info +from litellm.utils import get_model_info, supports_reasoning from ...openai.chat.o_series_transformation import OpenAIOSeriesConfig @@ -38,7 +38,9 @@ class AzureOpenAIO1Config(OpenAIOSeriesConfig): "top_logprobs", ] - o_series_only_param = ["reasoning_effort"] + o_series_only_param = [] + if supports_reasoning(model): + o_series_only_param.append("reasoning_effort") all_openai_params.extend(o_series_only_param) return [ param for param in all_openai_params if param not in non_supported_params From 962fd672279fa2c0220309cc1296bd7951724c23 Mon Sep 17 00:00:00 2001 From: Juan Cruz-Benito Date: Mon, 23 Jun 2025 19:14:10 +0200 Subject: [PATCH 10/45] Fixing watsonx error: 'model_id' or 'model' cannot be specified in the request body for models in a deployment space (#11854) * Fixing watsonx error: 'model_id' or 'model' cannot be specified in the request body for models in a deployment space * Revert "Fixing watsonx error: 'model_id' or 'model' cannot be specified in the request body for models in a deployment space" This reverts commit 9d16a3000b24d255f70ab74c01eb6267354df151. * Implementing feedback from code review --- litellm/llms/watsonx/chat/handler.py | 4 +- litellm/llms/watsonx/chat/transformation.py | 14 +- .../test_litellm/llms/watsonx/test_watsonx.py | 205 ++++++++++++++++++ 3 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/llms/watsonx/test_watsonx.py diff --git a/litellm/llms/watsonx/chat/handler.py b/litellm/llms/watsonx/chat/handler.py index 45378c55292..5c19757fecb 100644 --- a/litellm/llms/watsonx/chat/handler.py +++ b/litellm/llms/watsonx/chat/handler.py @@ -52,7 +52,7 @@ class WatsonXChatHandler(OpenAILikeChatHandler): litellm_params=litellm_params, ) - ## UPDATE PAYLOAD (optional params) + ## UPDATE PAYLOAD (optional params and special cases for models deployed in spaces) watsonx_auth_payload = watsonx_chat_transformation._prepare_payload( model=model, api_params=api_params, @@ -70,7 +70,7 @@ class WatsonXChatHandler(OpenAILikeChatHandler): ) return super().completion( - model=model, + model=watsonx_auth_payload.get("model_id", None), messages=messages, api_base=api_base, custom_llm_provider=custom_llm_provider, diff --git a/litellm/llms/watsonx/chat/transformation.py b/litellm/llms/watsonx/chat/transformation.py index 3c2d1c6f0bf..71d8bba4ef4 100644 --- a/litellm/llms/watsonx/chat/transformation.py +++ b/litellm/llms/watsonx/chat/transformation.py @@ -7,7 +7,7 @@ Docs: https://cloud.ibm.com/apidocs/watsonx-ai#text-chat from typing import List, Optional, Tuple, Union from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.watsonx import WatsonXAIEndpoint +from litellm.types.llms.watsonx import WatsonXAIEndpoint, WatsonXAPIParams from ....utils import _remove_additional_properties, _remove_strict_from_schema from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -108,3 +108,15 @@ class IBMWatsonXChatConfig(IBMWatsonXMixin, OpenAIGPTConfig): url=url, api_version=optional_params.pop("api_version", None) ) return url + + def _prepare_payload(self, model: str, api_params: WatsonXAPIParams) -> dict: + """ + Prepare payload for deployment models. + Deployment models cannot have 'model_id' or 'model' in the request body. + """ + payload: dict = {} + payload["model_id"] = None if model.startswith("deployment/") else model + payload["project_id"] = ( + None if model.startswith("deployment/") else api_params["project_id"] + ) + return payload diff --git a/tests/test_litellm/llms/watsonx/test_watsonx.py b/tests/test_litellm/llms/watsonx/test_watsonx.py new file mode 100644 index 00000000000..13ca6ad8e6c --- /dev/null +++ b/tests/test_litellm/llms/watsonx/test_watsonx.py @@ -0,0 +1,205 @@ +import json +import os +import sys + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path +import litellm +from litellm import completion +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from unittest.mock import patch, Mock +import pytest +from typing import Optional + + +@pytest.fixture +def watsonx_chat_completion_call(): + def _call( + model="watsonx/my-test-model", + messages=None, + api_key="test_api_key", + space_id: Optional[str] = None, + headers=None, + client=None, + patch_token_call=True, + ): + if messages is None: + messages = [{"role": "user", "content": "Hello, how are you?"}] + if client is None: + client = HTTPHandler() + + if patch_token_call: + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "mock_access_token", + "expires_in": 3600, + } + mock_response.raise_for_status = Mock() # No-op to simulate no exception + + with patch.object(client, "post") as mock_post, patch.object( + litellm.module_level_client, "post", return_value=mock_response + ) as mock_get: + try: + completion( + model=model, + messages=messages, + api_key=api_key, + headers=headers or {}, + client=client, + space_id=space_id, + ) + except Exception as e: + print(e) + + return mock_post, mock_get + else: + with patch.object(client, "post") as mock_post: + try: + completion( + model=model, + messages=messages, + api_key=api_key, + headers=headers or {}, + client=client, + space_id=space_id, + ) + except Exception as e: + print(e) + return mock_post, None + + return _call + + +def test_watsonx_deployment_model_id_not_in_payload( + monkeypatch, watsonx_chat_completion_call +): + """Test that deployment models do not include 'model_id' in the request payload""" + monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id") + monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai") + model = "watsonx/deployment/test-deployment-id" + messages = [{"role": "user", "content": "Test message"}] + + mock_post, _ = watsonx_chat_completion_call(model=model, messages=messages) + + assert mock_post.call_count == 1 + json_data = json.loads(mock_post.call_args.kwargs["data"]) + # Ensure model_id is not in the payload for deployment models + assert "model_id" not in json_data or json_data["model_id"] is None + # Ensure project_id is also not in the payload for deployment models + assert "project_id" not in json_data or json_data["project_id"] is None + + +def test_watsonx_regular_model_includes_model_id( + monkeypatch, watsonx_chat_completion_call +): + """Test that regular models include 'model_id' in the request payload""" + monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id") + monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai") + model = "watsonx/regular-model" + messages = [{"role": "user", "content": "Test message"}] + + mock_post, _ = watsonx_chat_completion_call(model=model, messages=messages) + + assert mock_post.call_count == 1 + json_data = json.loads(mock_post.call_args.kwargs["data"]) + # Ensure model_id is included in the payload for regular models + assert "model_id" in json_data + assert json_data["model_id"] == "regular-model" # Provider prefix is stripped + # Ensure project_id is also included for regular models + assert "project_id" in json_data + + +@pytest.fixture +def watsonx_completion_call(): + def _call( + model="watsonx_text/my-test-model", + prompt="Hello, how are you?", + api_key="test_api_key", + space_id: Optional[str] = None, + headers=None, + client=None, + patch_token_call=True, + ): + if client is None: + client = HTTPHandler() + + if patch_token_call: + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "mock_access_token", + "expires_in": 3600, + } + mock_response.raise_for_status = Mock() + + with patch.object(client, "post") as mock_post, patch.object( + litellm.module_level_client, "post", return_value=mock_response + ) as mock_get: + try: + litellm.text_completion( + model=model, + prompt=prompt, + api_key=api_key, + headers=headers or {}, + client=client, + space_id=space_id, + ) + except Exception as e: + print(e) + + return mock_post, mock_get + else: + with patch.object(client, "post") as mock_post: + try: + litellm.text_completion( + model=model, + prompt=prompt, + api_key=api_key, + headers=headers or {}, + client=client, + space_id=space_id, + ) + except Exception as e: + print(e) + return mock_post, None + + return _call + + +def test_watsonx_completion_deployment_model_id_not_in_payload( + monkeypatch, watsonx_completion_call +): + """Test that deployment models do not include 'model_id' in completion request payload""" + monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id") + monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai") + model = "watsonx_text/deployment/test-deployment-id" + prompt = "Test prompt" + + mock_post, _ = watsonx_completion_call(model=model, prompt=prompt) + + assert mock_post.call_count == 1 + json_data = json.loads(mock_post.call_args.kwargs["data"]) + # Ensure model_id is not in the payload for deployment models + assert "model_id" not in json_data + # Ensure project_id is also not in the payload for deployment models + assert "project_id" not in json_data + + +def test_watsonx_completion_regular_model_includes_model_id( + monkeypatch, watsonx_completion_call +): + """Test that regular models include 'model_id' in completion request payload""" + monkeypatch.setenv("WATSONX_PROJECT_ID", "test-project-id") + monkeypatch.setenv("WATSONX_API_BASE", "https://test-api.watsonx.ai") + model = "watsonx_text/regular-model" + prompt = "Test prompt" + + mock_post, _ = watsonx_completion_call(model=model, prompt=prompt) + + assert mock_post.call_count == 1 + json_data = json.loads(mock_post.call_args.kwargs["data"]) + # Ensure model_id is included in the payload for regular models + assert "model_id" in json_data + assert json_data["model_id"] == "regular-model" # Provider prefix is stripped + # Ensure project_id is also included for regular models + assert "project_id" in json_data From ef7f8cce9340a596d4fdae3496c6c84dcc4100c4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 10:21:50 -0700 Subject: [PATCH 11/45] [Bug Fix] Perplexity - LiteLLM doesn't support 'web_search_options' for Perplexity' Sonar Pro model (#11983) * TestPerplexityWebSearch * use supports_web_search * Update tests/test_litellm/llms/perplexity/test_perplexity.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../llms/perplexity/chat/transformation.py | 9 +++++++ .../llms/perplexity/test_perplexity.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/test_litellm/llms/perplexity/test_perplexity.py diff --git a/litellm/llms/perplexity/chat/transformation.py b/litellm/llms/perplexity/chat/transformation.py index 4ce2df51b6e..a81400870a7 100644 --- a/litellm/llms/perplexity/chat/transformation.py +++ b/litellm/llms/perplexity/chat/transformation.py @@ -55,4 +55,13 @@ class PerplexityChatConfig(OpenAIGPTConfig): base_openai_params.append("reasoning_effort") except Exception as e: verbose_logger.debug(f"Error checking if model supports reasoning: {e}") + + try: + if litellm.supports_web_search( + model=model, custom_llm_provider=self.custom_llm_provider + ): + base_openai_params.append("web_search_options") + except Exception as e: + verbose_logger.debug(f"Error checking if model supports web search: {e}") + return base_openai_params diff --git a/tests/test_litellm/llms/perplexity/test_perplexity.py b/tests/test_litellm/llms/perplexity/test_perplexity.py new file mode 100644 index 00000000000..5c8eead4d6d --- /dev/null +++ b/tests/test_litellm/llms/perplexity/test_perplexity.py @@ -0,0 +1,25 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath("../../..")) + +import pytest + + +class TestPerplexityWebSearch: + """Test suite for Perplexity web search functionality.""" + + @pytest.mark.parametrize( + "model", + ["perplexity/sonar", "perplexity/sonar-pro"] + ) + def test_web_search_options_in_supported_params(self, model): + """ + Test that web_search_options is in the list of supported parameters for Perplexity sonar models + """ + from litellm.llms.perplexity.chat.transformation import PerplexityChatConfig + + config = PerplexityChatConfig() + supported_params = config.get_supported_openai_params(model=model) + + assert "web_search_options" in supported_params, f"web_search_options should be supported for {model}" From 02a095d4db9fbb5df04af8bc7d8e7fd001b10d8b Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:15:25 -0600 Subject: [PATCH 12/45] feat: implement Perplexity citation tokens and search queries cost calculation (#11938) * feat: add citation_cost_per_token and search_queries_cost_per_1000 fields to ModelInfoBase - Add citation_cost_per_token field to ModelInfoBase for Perplexity citation token costs - Add search_queries_cost_per_1000 field to ModelInfoBase for Perplexity search query costs - Update _get_model_info_helper to include these fields in model info responses - Enables proper cost calculation for Perplexity-specific usage metrics * feat: update Perplexity sonar-deep-research model pricing configuration - Update input/output token costs to / per million tokens respectively - Add reasoning token cost at per million tokens - Add citation_cost_per_token at per million tokens (same as input) - Add search_queries_cost_per_1000 at /bin/zsh.005 per 1000 search queries - Remove deprecated search_context_cost_per_query structure - Aligns with Perplexity's updated pricing model for deep research capabilities * feat: implement Perplexity-specific cost calculator - Create cost_per_token function for Perplexity provider - Calculate standard input/output token costs - Add citation token cost calculation using citation_cost_per_token rate - Add reasoning token cost calculation with fallback to completion_tokens_details - Add search query cost calculation using search_queries_cost_per_1000 rate - Return separate prompt_cost and completion_cost for accurate billing - Handles all Perplexity-specific usage metrics: citation_tokens, num_search_queries, reasoning_tokens * feat: integrate Perplexity cost calculator with main cost calculation system - Import perplexity_cost_per_token function in main cost calculator - Add perplexity provider case to cost_per_token function - Enables automatic routing of Perplexity cost calculations to provider-specific logic - Maintains compatibility with existing cost calculation patterns - Supports all Perplexity-specific cost metrics through unified interface * feat: enhance Perplexity response transformation to extract cost-related fields - Override transform_response method to extract Perplexity-specific usage fields - Add _enhance_usage_with_perplexity_fields method to process API responses - Extract citation_tokens from citations array using character-based estimation (~4 chars/token) - Extract num_search_queries from both usage field and root level with priority handling - Create usage object when none exists to ensure cost fields are always captured - Handle empty citations and missing fields gracefully - Enables automatic extraction of cost metrics from Perplexity API responses * test: add comprehensive test suite for Perplexity cost calculation features Add 82 comprehensive tests across 3 test files: - test_perplexity_cost_calculator.py (59 tests): * Cost calculation with citation tokens, search queries, reasoning tokens * Various combinations and edge cases * Integration with main cost calculator * Model info access and validation * Zero values and missing fields handling - test_perplexity_chat_transformation.py (12 tests): * Citation token extraction from API responses * Search query extraction from usage and root fields * Priority handling and field aggregation * Empty citations and missing fields handling * Token estimation accuracy validation - test_perplexity_integration.py (11 tests): * End-to-end cost calculation workflows * High-volume and edge case scenarios * Model info integration validation * Case-insensitive provider matching * Transformation preservation of existing fields Ensures reliability and correctness of all Perplexity cost features with comprehensive coverage of happy path, edge cases, and error conditions. * fix: remove unused Union import from Perplexity transformation - Remove unused typing.Union import from litellm/llms/perplexity/chat/transformation.py - Fixes F401 linting error: 'typing.Union imported but unused' - Maintains only necessary imports: Any, List, Optional, Tuple * Fix JSON schema validation and use web_search_requests field - Add citation_cost_per_token and search_queries_cost_per_1000 to JSON schema - Update Perplexity transformation to use web_search_requests in PromptTokensDetailsWrapper - Update Perplexity cost calculator to read from web_search_requests field - Maintain backward compatibility while using standard LiteLLM fields * Fix type errors in Perplexity cost calculator - Add null checks for token counts and cost values to prevent None multiplication errors - Use .get() with fallback values instead of direct dictionary access - Ensure all arithmetic operations handle None values safely This fixes the failing job 44517525148 type errors. * Refactor Perplexity cost calculation tests to improve accuracy and consistency - Replace absolute difference assertions with math.isclose for better precision in cost comparisons - Update tests to utilize PromptTokensDetailsWrapper for handling web search requests - Ensure all test cases correctly reflect the new structure of usage fields, enhancing clarity and maintainability * fix: address type hinting issues in PerplexityChatConfig usage handling - Add type ignore comments to model_response.usage assignments to resolve type checking errors - Ensures compatibility with type definitions while maintaining existing functionality * Update model pricing configuration in JSON backup - Add citation_cost_per_token and search_queries_cost_per_1000 fields to enhance cost tracking - Remove deprecated search_context_cost_per_query structure to streamline pricing model - Aligns with recent updates in Perplexity's pricing strategy * Update search queries cost structure in model_prices_and_context_window.json to use search_context_cost_per_query * Refactor search queries cost structure in model_prices_and_context_window_backup.json and update related code to use search_queries_cost_per_query. Remove deprecated search_queries_cost_per_1000 references across model info and tests. * Enhance cost calculation in cost_calculator.py by introducing a safe float casting function to handle potential None and invalid values. Update cost calculations for input, citation, output, reasoning, and search query tokens to use this new function, ensuring more robust handling of model pricing data. * Refactor cost calculation in cost_calculator.py to support both legacy and current search cost keys. Enhance handling of search cost values by accommodating both dictionary and float formats, ensuring robust cost computation for search queries. * Update test cases to reflect changes in cost structure, renaming search_queries_cost_per_query to search_context_cost_per_query for consistency with recent refactor. Ensure assertions in tests align with updated cost keys. * Update test_perplexity_integration.py to rename search_queries_cost_per_query to search_context_cost_per_query, ensuring consistency with recent cost structure changes. Adjust assertions to align with updated cost keys. --- litellm/cost_calculator.py | 5 + .../llms/perplexity/chat/transformation.py | 102 ++++- litellm/llms/perplexity/cost_calculator.py | 79 ++++ ...odel_prices_and_context_window_backup.json | 11 +- litellm/types/utils.py | 2 +- litellm/utils.py | 1 + model_prices_and_context_window.json | 5 +- .../test_perplexity_chat_transformation.py | 409 ++++++++++++++++++ .../test_perplexity_cost_calculator.py | 373 ++++++++++++++++ .../perplexity/test_perplexity_integration.py | 319 ++++++++++++++ tests/test_litellm/test_utils.py | 1 + 11 files changed, 1296 insertions(+), 11 deletions(-) create mode 100644 litellm/llms/perplexity/cost_calculator.py create mode 100644 tests/test_litellm/llms/perplexity/chat/test_perplexity_chat_transformation.py create mode 100644 tests/test_litellm/llms/perplexity/test_perplexity_cost_calculator.py create mode 100644 tests/test_litellm/llms/perplexity/test_perplexity_integration.py diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 69a14a7aa7a..30658cb091a 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -45,6 +45,9 @@ from litellm.llms.openai.cost_calculation import ( cost_per_second as openai_cost_per_second, ) from litellm.llms.openai.cost_calculation import cost_per_token as openai_cost_per_token +from litellm.llms.perplexity.cost_calculator import ( + cost_per_token as perplexity_cost_per_token, +) from litellm.llms.together_ai.cost_calculator import get_model_params_and_category from litellm.llms.vertex_ai.cost_calculator import ( cost_per_character as google_cost_per_character, @@ -329,6 +332,8 @@ def cost_per_token( # noqa: PLR0915 return gemini_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "deepseek": return deepseek_cost_per_token(model=model, usage=usage_block) + elif custom_llm_provider == "perplexity": + return perplexity_cost_per_token(model=model, usage=usage_block) else: model_info = _cached_get_model_info_helper( model=model, custom_llm_provider=custom_llm_provider diff --git a/litellm/llms/perplexity/chat/transformation.py b/litellm/llms/perplexity/chat/transformation.py index a81400870a7..955fdff0818 100644 --- a/litellm/llms/perplexity/chat/transformation.py +++ b/litellm/llms/perplexity/chat/transformation.py @@ -2,13 +2,17 @@ Translate from OpenAI's `/v1/chat/completions` to Perplexity's `/v1/chat/completions` """ -from typing import Optional, Tuple +from typing import Any, List, Optional, Tuple +import httpx import litellm from litellm._logging import verbose_logger from litellm.secret_managers.main import get_secret_str - -from ...openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import Usage, PromptTokensDetailsWrapper +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.types.utils import ModelResponse class PerplexityChatConfig(OpenAIGPTConfig): @@ -65,3 +69,95 @@ class PerplexityChatConfig(OpenAIGPTConfig): verbose_logger.debug(f"Error checking if model supports web search: {e}") return base_openai_params + + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ModelResponse: + # Call the parent transform_response first to handle the standard transformation + model_response = super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + # Extract and enhance usage with Perplexity-specific fields + try: + raw_response_json = raw_response.json() + self._enhance_usage_with_perplexity_fields(model_response, raw_response_json) + except Exception as e: + verbose_logger.debug(f"Error extracting Perplexity-specific usage fields: {e}") + + return model_response + + def _enhance_usage_with_perplexity_fields( + self, model_response: ModelResponse, raw_response_json: dict + ) -> None: + """ + Extract citation tokens and search queries from Perplexity API response + and add them to the usage object using standard LiteLLM fields. + """ + if not hasattr(model_response, "usage") or model_response.usage is None: + # Create a usage object if it doesn't exist (when usage was None) + model_response.usage = Usage( # type: ignore[attr-defined] + prompt_tokens=0, + completion_tokens=0, + total_tokens=0 + ) + + usage = model_response.usage # type: ignore[attr-defined] + + # Extract citation tokens count + citations = raw_response_json.get("citations", []) + citation_tokens = 0 + if citations: + # Count total characters in citations as a proxy for citation tokens + # This is an estimation - in practice, you might want to use proper tokenization + total_citation_chars = sum(len(str(citation)) for citation in citations if citation) + # Rough estimation: ~4 characters per token (OpenAI's general rule) + if total_citation_chars > 0: + citation_tokens = max(1, total_citation_chars // 4) + + # Extract search queries count from usage or response metadata + # Perplexity might include this in the usage object or as separate metadata + perplexity_usage = raw_response_json.get("usage", {}) + + # Try to extract search queries from usage field first, then root level + num_search_queries = perplexity_usage.get("num_search_queries") + if num_search_queries is None: + num_search_queries = raw_response_json.get("num_search_queries") + if num_search_queries is None: + num_search_queries = perplexity_usage.get("search_queries") + if num_search_queries is None: + num_search_queries = raw_response_json.get("search_queries") + + # Create or update prompt_tokens_details to include web search requests and citation tokens + if citation_tokens > 0 or (num_search_queries is not None and num_search_queries > 0): + if usage.prompt_tokens_details is None: + usage.prompt_tokens_details = PromptTokensDetailsWrapper() + + # Store citation tokens count for cost calculation + if citation_tokens > 0: + setattr(usage, "citation_tokens", citation_tokens) + + # Store search queries count in the standard web_search_requests field + if num_search_queries is not None and num_search_queries > 0: + usage.prompt_tokens_details.web_search_requests = num_search_queries diff --git a/litellm/llms/perplexity/cost_calculator.py b/litellm/llms/perplexity/cost_calculator.py new file mode 100644 index 00000000000..c8fd2a682a8 --- /dev/null +++ b/litellm/llms/perplexity/cost_calculator.py @@ -0,0 +1,79 @@ +""" +Helper util for handling perplexity-specific cost calculation +- e.g.: citation tokens, search queries +""" + +from typing import Tuple, Union + +from litellm.types.utils import Usage +from litellm.utils import get_model_info + + +def cost_per_token(model: str, usage: Usage) -> Tuple[float, float]: + """ + Calculates the cost per token for a given model, prompt tokens, and completion tokens. + + Input: + - model: str, the model name without provider prefix + - usage: LiteLLM Usage block, containing perplexity-specific usage information + + Returns: + Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd + """ + ## GET MODEL INFO + model_info = get_model_info(model=model, custom_llm_provider="perplexity") + + def _safe_float_cast(value: Union[str, int, float, None, object], default: float = 0.0) -> float: + """Safely cast a value to float with proper type handling for mypy.""" + if value is None: + return default + try: + return float(value) # type: ignore + except (ValueError, TypeError): + return default + + ## CALCULATE INPUT COST + input_cost_per_token = _safe_float_cast(model_info.get("input_cost_per_token")) + prompt_cost: float = (usage.prompt_tokens or 0) * input_cost_per_token + + ## ADD CITATION TOKENS COST (if present) + citation_tokens = getattr(usage, "citation_tokens", 0) or 0 + citation_cost_value = model_info.get("citation_cost_per_token") + if citation_tokens > 0 and citation_cost_value is not None: + citation_cost_per_token = _safe_float_cast(citation_cost_value) + prompt_cost += citation_tokens * citation_cost_per_token + + ## CALCULATE OUTPUT COST + output_cost_per_token = _safe_float_cast(model_info.get("output_cost_per_token")) + completion_cost: float = (usage.completion_tokens or 0) * output_cost_per_token + + ## ADD REASONING TOKENS COST (if present) + reasoning_tokens = getattr(usage, "reasoning_tokens", 0) or 0 + # Also check completion_tokens_details if reasoning_tokens is not directly available + if reasoning_tokens == 0 and hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details: + reasoning_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0 + + reasoning_cost_value = model_info.get("output_cost_per_reasoning_token") + if reasoning_tokens > 0 and reasoning_cost_value is not None: + reasoning_cost_per_token = _safe_float_cast(reasoning_cost_value) + completion_cost += reasoning_tokens * reasoning_cost_per_token + + ## ADD SEARCH QUERIES COST (if present) + num_search_queries = 0 + if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: + num_search_queries = getattr(usage.prompt_tokens_details, "web_search_requests", 0) or 0 + + # Check both possible keys for search cost (legacy and current) + search_cost_value = model_info.get("search_queries_cost_per_query") or model_info.get("search_context_cost_per_query") + if num_search_queries > 0 and search_cost_value is not None: + # Handle both dict and float formats + if isinstance(search_cost_value, dict): + # Use the "low" size as default - tests expect 0.005 / 1000 + search_cost_per_query = _safe_float_cast(search_cost_value.get("search_context_size_low", 0)) / 1000 + else: + search_cost_per_query = _safe_float_cast(search_cost_value) + search_cost = num_search_queries * search_cost_per_query + # Add search cost to completion cost (similar to how other providers handle it) + completion_cost += search_cost + + return prompt_cost, completion_cost \ No newline at end of file diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9d72f852de1..023fc3f935a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13647,13 +13647,14 @@ "input_cost_per_token": 2e-06, "output_cost_per_token": 8e-06, "output_cost_per_reasoning_token": 3e-06, + "citation_cost_per_token": 2e-06, + "search_queries_cost_per_query": { + "search_queries_size_low": 0.005, + "search_queries_size_medium": 0.005, + "search_queries_size_high": 0.005 + }, "litellm_provider": "perplexity", "mode": "chat", - "search_context_cost_per_query": { - "search_context_size_low": 0.005, - "search_context_size_medium": 0.005, - "search_context_size_high": 0.005 - }, "supports_reasoning": true, "supports_web_search": true }, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index fd8ecd9e14a..14be87704fb 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -177,7 +177,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): search_context_cost_per_query: Optional[ SearchContextCostPerQuery ] # Cost for using web search tool - + citation_cost_per_token: Optional[float] # Cost per citation token for Perplexity litellm_provider: Required[str] mode: Required[ Literal[ diff --git a/litellm/utils.py b/litellm/utils.py index e3b14a16763..dacc1680458 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4650,6 +4650,7 @@ def _get_model_info_helper( # noqa: PLR0915 output_cost_per_second=_model_info.get("output_cost_per_second", None), output_cost_per_image=_model_info.get("output_cost_per_image", None), output_vector_size=_model_info.get("output_vector_size", None), + citation_cost_per_token=_model_info.get("citation_cost_per_token", None), litellm_provider=_model_info.get( "litellm_provider", custom_llm_provider ), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9d72f852de1..8d4e5e276e7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13647,13 +13647,14 @@ "input_cost_per_token": 2e-06, "output_cost_per_token": 8e-06, "output_cost_per_reasoning_token": 3e-06, - "litellm_provider": "perplexity", - "mode": "chat", + "citation_cost_per_token": 2e-06, "search_context_cost_per_query": { "search_context_size_low": 0.005, "search_context_size_medium": 0.005, "search_context_size_high": 0.005 }, + "litellm_provider": "perplexity", + "mode": "chat", "supports_reasoning": true, "supports_web_search": true }, diff --git a/tests/test_litellm/llms/perplexity/chat/test_perplexity_chat_transformation.py b/tests/test_litellm/llms/perplexity/chat/test_perplexity_chat_transformation.py new file mode 100644 index 00000000000..6f64f46b4a7 --- /dev/null +++ b/tests/test_litellm/llms/perplexity/chat/test_perplexity_chat_transformation.py @@ -0,0 +1,409 @@ +""" +Test file for Perplexity chat transformation functionality. + +Tests the response transformation to extract citation tokens and search queries +from Perplexity API responses. +""" + +import os +import sys +from unittest.mock import Mock + +import pytest + +# Add the project root to Python path +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm import ModelResponse +from litellm.llms.perplexity.chat.transformation import PerplexityChatConfig +from litellm.types.utils import Usage + + +class TestPerplexityChatTransformation: + """Test suite for Perplexity chat transformation functionality.""" + + def test_enhance_usage_with_citation_tokens(self): + """Test extraction of citation tokens from API response.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with citations + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + }, + "citations": [ + "This is a citation with some text content", + "Another citation with more text here", + "Third citation with additional information" + ] + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Check that citation tokens were added + assert hasattr(model_response.usage, "citation_tokens") + citation_tokens = getattr(model_response.usage, "citation_tokens") + + # Should have extracted citation tokens (estimated based on character count) + assert citation_tokens > 0 + assert isinstance(citation_tokens, int) + + def test_enhance_usage_with_search_queries_from_usage(self): + """Test extraction of search queries from usage field in API response.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with search queries in usage + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 3 + } + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Check that search queries were added to prompt_tokens_details + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + assert hasattr(model_response.usage.prompt_tokens_details, "web_search_requests") + + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + assert web_search_requests == 3 + + def test_enhance_usage_with_search_queries_from_root(self): + """Test extraction of search queries from root level in API response.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with search queries at root level + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + }, + "num_search_queries": 2 + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Check that search queries were added to prompt_tokens_details + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + assert hasattr(model_response.usage.prompt_tokens_details, "web_search_requests") + + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + assert web_search_requests == 2 + + def test_enhance_usage_with_both_citations_and_search_queries(self): + """Test extraction of both citation tokens and search queries.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with both citations and search queries + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 2 + }, + "citations": [ + "Citation one with some content", + "Citation two with more information" + ] + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Check that both fields were added + assert hasattr(model_response.usage, "citation_tokens") + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + assert hasattr(model_response.usage.prompt_tokens_details, "web_search_requests") + + citation_tokens = getattr(model_response.usage, "citation_tokens") + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + + assert citation_tokens > 0 + assert web_search_requests == 2 + + def test_enhance_usage_with_empty_citations(self): + """Test handling of empty citations array.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with empty citations + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + }, + "citations": [] + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Should not set citation_tokens for empty citations + citation_tokens = getattr(model_response.usage, "citation_tokens", 0) + assert citation_tokens == 0 + + def test_enhance_usage_with_missing_fields(self): + """Test handling when both citations and search queries are missing.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response without citations or search queries + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } + } + + # Should not raise an error + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Should not have added custom fields + citation_tokens = getattr(model_response.usage, "citation_tokens", 0) + assert citation_tokens == 0 + + # prompt_tokens_details might be None or have web_search_requests as 0 + if hasattr(model_response.usage, "prompt_tokens_details") and model_response.usage.prompt_tokens_details: + web_search_requests = getattr(model_response.usage.prompt_tokens_details, "web_search_requests", 0) + assert web_search_requests == 0 + + def test_citation_token_estimation(self): + """Test that citation token estimation is reasonable.""" + config = PerplexityChatConfig() + + # Test cases with known character counts + test_cases = [ + # (citation_text, expected_min_tokens, expected_max_tokens) + ("Short", 1, 2), + ("This is a longer citation with multiple words", 10, 15), + ("A very long citation with many words and characters that should result in more tokens", 18, 25), + ] + + for citation_text, min_tokens, max_tokens in test_cases: + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + raw_response_dict = { + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, + "citations": [citation_text] + } + + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + citation_tokens = getattr(model_response.usage, "citation_tokens") + + # Should be within reasonable range + assert min_tokens <= citation_tokens <= max_tokens, f"Citation '{citation_text}' resulted in {citation_tokens} tokens, expected {min_tokens}-{max_tokens}" + + def test_multiple_citations_aggregation(self): + """Test that multiple citations are aggregated correctly.""" + config = PerplexityChatConfig() + + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + raw_response_dict = { + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, + "citations": [ + "First citation with some text", + "Second citation with different content", + "Third citation with more information" + ] + } + + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + citation_tokens = getattr(model_response.usage, "citation_tokens") + + # Should have aggregated all citations + total_chars = sum(len(citation) for citation in raw_response_dict["citations"]) + expected_tokens = total_chars // 4 # Our estimation logic + + assert citation_tokens == expected_tokens + + def test_search_queries_priority_usage_over_root(self): + """Test that search queries from usage field take priority over root level.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Mock raw response with search queries in both locations + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 5 # This should take priority + }, + "num_search_queries": 3 # This should be ignored + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Check that usage field took priority + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + + assert web_search_requests == 5 # Should use the usage field value, not root + + def test_no_usage_object_handling(self): + """Test handling when model_response has no usage object.""" + config = PerplexityChatConfig() + + # Create a ModelResponse without usage + model_response = ModelResponse() + + # Mock raw response with Perplexity-specific fields + raw_response_dict = { + "choices": [{"message": {"content": "Test response"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 2 + }, + "citations": ["Some citation"] + } + + # Should not raise an error when usage is None + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Usage should be created with the Perplexity fields + assert model_response.usage is not None + assert hasattr(model_response.usage, "citation_tokens") + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + assert hasattr(model_response.usage.prompt_tokens_details, "web_search_requests") + + citation_tokens = getattr(model_response.usage, "citation_tokens") + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + + assert citation_tokens > 0 + assert web_search_requests == 2 + + @pytest.mark.parametrize("search_query_location", ["usage", "root"]) + def test_search_queries_extraction_locations(self, search_query_location): + """Test search queries extraction from different response locations.""" + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Create response dict based on parameter + if search_query_location == "usage": + raw_response_dict = { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 4 + } + } + else: # root + raw_response_dict = { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + }, + "num_search_queries": 4 + } + + # Enhance the usage with Perplexity fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Should extract search queries from either location + assert hasattr(model_response.usage, "prompt_tokens_details") + assert model_response.usage.prompt_tokens_details is not None + web_search_requests = model_response.usage.prompt_tokens_details.web_search_requests + + assert web_search_requests == 4 \ No newline at end of file diff --git a/tests/test_litellm/llms/perplexity/test_perplexity_cost_calculator.py b/tests/test_litellm/llms/perplexity/test_perplexity_cost_calculator.py new file mode 100644 index 00000000000..f9a52100070 --- /dev/null +++ b/tests/test_litellm/llms/perplexity/test_perplexity_cost_calculator.py @@ -0,0 +1,373 @@ +""" +Test file for Perplexity cost calculator functionality. + +Tests the cost calculation for Perplexity models including citation tokens, +search queries, and reasoning tokens. +""" + +import json +import math +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +# Add the project root to Python path +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +from litellm.cost_calculator import completion_cost, cost_per_token +from litellm.llms.perplexity.cost_calculator import cost_per_token as perplexity_cost_per_token +from litellm.types.utils import Usage, PromptTokensDetailsWrapper +from litellm.utils import get_model_info + + +class TestPerplexityCostCalculator: + """Test suite for Perplexity cost calculation functionality.""" + + @pytest.fixture(autouse=True) + def setup_model_cost_map(self): + """Set up the model cost map for testing.""" + # Ensure we use local model cost map for consistent testing + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + + # Load the model cost map + try: + with open("model_prices_and_context_window.json", "r") as f: + model_cost_map = json.load(f) + litellm.model_cost = model_cost_map + except FileNotFoundError: + # Fallback to ensure we have the Perplexity model configuration + litellm.model_cost = { + "perplexity/sonar-deep-research": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "input_cost_per_token": 2e-06, + "output_cost_per_token": 8e-06, + "output_cost_per_reasoning_token": 3e-06, + "citation_cost_per_token": 2e-06, + "search_context_cost_per_query": { + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005, + "search_context_size_high": 0.005 + }, + "litellm_provider": "perplexity", + "mode": "chat", + "supports_reasoning": True, + "supports_web_search": True, + } + } + + def test_basic_cost_calculation(self): + """Test basic cost calculation without additional fields.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Output: 50 tokens * $8e-6 = $0.0004 + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = 50 * 8e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_citation_tokens_cost_calculation(self): + """Test cost calculation with citation tokens.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Add citation tokens + usage.citation_tokens = 25 + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Citation: 25 tokens * $2e-6 = $0.00005 + # Total prompt cost: $0.00025 + # Output: 50 tokens * $8e-6 = $0.0004 + expected_prompt_cost = (100 * 2e-6) + (25 * 2e-6) + expected_completion_cost = 50 * 8e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_search_queries_cost_calculation(self): + """Test cost calculation with search queries.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=3) + ) + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Output: 50 tokens * $8e-6 = $0.0004 + # Search: 3 queries * ($0.005 / 1000) = $0.000015 + # Total completion cost: $0.000415 + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = (50 * 8e-6) + (3 / 1000 * 0.005) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_reasoning_tokens_from_direct_attribute(self): + """Test reasoning tokens cost calculation from direct attribute.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Set reasoning tokens directly + usage.reasoning_tokens = 20 + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Output: 50 tokens * $8e-6 = $0.0004 + # Reasoning: 20 tokens * $3e-6 = $0.00006 + # Total completion cost: $0.00046 + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = (50 * 8e-6) + (20 * 3e-6) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_reasoning_tokens_from_completion_tokens_details(self): + """Test reasoning tokens cost calculation from completion_tokens_details.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=20 # This should be stored in completion_tokens_details + ) + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Output: 50 tokens * $8e-6 = $0.0004 + # Reasoning: 20 tokens * $3e-6 = $0.00006 + # Total completion cost: $0.00046 + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = (50 * 8e-6) + (20 * 3e-6) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_comprehensive_cost_calculation(self): + """Test cost calculation with all fields combined.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=15, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=2) + ) + + # Add custom fields + usage.citation_tokens = 30 + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Expected costs: + # Input: 100 tokens * $2e-6 = $0.0002 + # Citation: 30 tokens * $2e-6 = $0.00006 + # Total prompt cost: $0.00026 + # Output: 50 tokens * $8e-6 = $0.0004 + # Reasoning: 15 tokens * $3e-6 = $0.000045 + # Search: 2 queries * ($0.005 / 1000) = $0.00001 + # Total completion cost: $0.000455 + expected_prompt_cost = (100 * 2e-6) + (30 * 2e-6) + expected_completion_cost = (50 * 8e-6) + (15 * 3e-6) + (2 / 1000 * 0.005) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_zero_values_handling(self): + """Test that zero or missing values are handled correctly.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=0) + ) + + # These should not raise errors and should not affect cost + usage.citation_tokens = 0 + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Should be same as basic calculation + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = 50 * 8e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_missing_model_info_fields(self): + """Test behavior when model info is missing some fields.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=2) + ) + + usage.citation_tokens = 25 + + # Mock get_model_info to return incomplete model info + with patch('litellm.llms.perplexity.cost_calculator.get_model_info') as mock_get_model_info: + mock_get_model_info.return_value = { + "input_cost_per_token": 2e-6, + "output_cost_per_token": 8e-6, + # Missing search_queries_cost_per_query + } + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Should only calculate basic costs when fields are missing + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = 50 * 8e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + def test_integration_with_main_cost_calculator(self): + """Test integration with the main LiteLLM cost calculator.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=10, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=1) + ) + + usage.citation_tokens = 20 + + # Test main cost calculator + prompt_cost, completion_cost_val = cost_per_token( + model="sonar-deep-research", + custom_llm_provider="perplexity", + usage_object=usage + ) + + # Should match direct call to perplexity cost calculator + expected_prompt, expected_completion = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + assert math.isclose(prompt_cost, expected_prompt, rel_tol=1e-6) + assert math.isclose(completion_cost_val, expected_completion, rel_tol=1e-6) + + def test_integration_with_completion_cost_function(self): + """Test integration with the completion_cost function.""" + from litellm import ModelResponse + + # Create a mock ModelResponse + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=10, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=1) + ) + usage.citation_tokens = 15 + + response = ModelResponse() + response.usage = usage + response.model = "sonar-deep-research" + + # Test completion_cost function + total_cost = completion_cost(completion_response=response, custom_llm_provider="perplexity") + + # Calculate expected total cost + expected_prompt_cost = (100 * 2e-6) + (15 * 2e-6) # Input + citation + expected_completion_cost = (50 * 8e-6) + (10 * 3e-6) + (1 / 1000 * 0.005) # Output + reasoning + search + expected_total = expected_prompt_cost + expected_completion_cost + + assert math.isclose(total_cost, expected_total, rel_tol=1e-6) + + def test_model_info_access(self): + """Test that model info correctly returns the new cost fields.""" + model_info = get_model_info(model="sonar-deep-research", custom_llm_provider="perplexity") + + # Check that the new fields are accessible + assert "citation_cost_per_token" in model_info + assert model_info["citation_cost_per_token"] == 2e-6 + assert model_info["search_context_cost_per_query"] == { + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005, + "search_context_size_high": 0.005 + } + + @pytest.mark.parametrize("citation_tokens", [0, 10, 25, 100]) + @pytest.mark.parametrize("search_queries", [0, 1, 5, 10]) + @pytest.mark.parametrize("reasoning_tokens", [0, 15, 30]) + def test_cost_calculation_combinations(self, citation_tokens, search_queries, reasoning_tokens): + """Test various combinations of citation tokens, search queries, and reasoning tokens.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=reasoning_tokens, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=search_queries) + ) + + usage.citation_tokens = citation_tokens + + prompt_cost, completion_cost = perplexity_cost_per_token( + model="sonar-deep-research", + usage=usage + ) + + # Calculate expected costs + expected_prompt_cost = (100 * 2e-6) + (citation_tokens * 2e-6) + expected_completion_cost = (50 * 8e-6) + (reasoning_tokens * 3e-6) + (search_queries / 1000 * 0.005) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-6) + + # Ensure costs are non-negative + assert prompt_cost >= 0 + assert completion_cost >= 0 \ No newline at end of file diff --git a/tests/test_litellm/llms/perplexity/test_perplexity_integration.py b/tests/test_litellm/llms/perplexity/test_perplexity_integration.py new file mode 100644 index 00000000000..ae72b8a9625 --- /dev/null +++ b/tests/test_litellm/llms/perplexity/test_perplexity_integration.py @@ -0,0 +1,319 @@ +""" +Integration tests for Perplexity cost calculation and transformation. + +Tests the end-to-end functionality of Perplexity cost calculation +including integration with the main LiteLLM cost calculator. +""" + +import json +import math +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +# Add the project root to Python path +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +from litellm import ModelResponse +from litellm.cost_calculator import completion_cost, cost_per_token +from litellm.llms.perplexity.chat.transformation import PerplexityChatConfig +from litellm.types.utils import Usage, PromptTokensDetailsWrapper +from litellm.utils import get_model_info + + +class TestPerplexityIntegration: + """Integration test suite for Perplexity functionality.""" + + @pytest.fixture(autouse=True) + def setup_model_cost_map(self): + """Set up the model cost map for testing.""" + # Ensure we use local model cost map for consistent testing + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + + # Load the model cost map + try: + with open("model_prices_and_context_window.json", "r") as f: + model_cost_map = json.load(f) + litellm.model_cost = model_cost_map + except FileNotFoundError: + # Fallback to ensure we have the Perplexity model configuration + litellm.model_cost = { + "perplexity/sonar-deep-research": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "input_cost_per_token": 2e-06, + "output_cost_per_token": 8e-06, + "output_cost_per_reasoning_token": 3e-06, + "citation_cost_per_token": 2e-06, + "search_queries_cost_per_query": { + "search_queries_size_low": 0.005, + "search_queries_size_medium": 0.005, + "search_queries_size_high": 0.005 + }, + "litellm_provider": "perplexity", + "mode": "chat", + "supports_reasoning": True, + "supports_web_search": True, + } + } + + def test_end_to_end_cost_calculation_with_transformation(self): + """Test end-to-end cost calculation with response transformation.""" + # Create a Perplexity API response that includes citations and search queries + config = PerplexityChatConfig() + + # Create a ModelResponse with basic usage (before transformation) + model_response = ModelResponse() + model_response.model = "sonar-deep-research" + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=10 + ) + + # Simulate raw response from Perplexity API + raw_response_dict = { + "choices": [{"message": {"content": "Test response with citations"}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "num_search_queries": 2 + }, + "citations": [ + "This is the first citation with important information about the topic", + "Another citation providing additional context for the response" + ] + } + + # Apply transformation to extract Perplexity-specific fields + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Now calculate the cost with the enhanced usage + total_cost = completion_cost(completion_response=model_response, custom_llm_provider="perplexity") + + # Calculate expected cost + citation_chars = sum(len(citation) for citation in raw_response_dict["citations"]) + citation_tokens = citation_chars // 4 + + expected_prompt_cost = (100 * 2e-6) + (citation_tokens * 2e-6) # Input + citation + expected_completion_cost = (50 * 8e-6) + (10 * 3e-6) + (2 / 1000 * 0.005) # Output + reasoning + search + expected_total = expected_prompt_cost + expected_completion_cost + + assert math.isclose(total_cost, expected_total, rel_tol=1e-6) + + def test_cost_calculation_without_custom_fields(self): + """Test that cost calculation works normally when custom fields are absent.""" + # Create a standard response without Perplexity-specific fields + model_response = ModelResponse() + model_response.model = "sonar-deep-research" + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Calculate cost without custom fields + total_cost = completion_cost(completion_response=model_response, custom_llm_provider="perplexity") + + # Should only include basic input/output costs + expected_cost = (100 * 2e-6) + (50 * 8e-6) + + assert math.isclose(total_cost, expected_cost, rel_tol=1e-6) + + def test_main_cost_calculator_integration(self): + """Test integration with the main LiteLLM cost calculator.""" + # Create usage with all Perplexity fields + usage = Usage( + prompt_tokens=200, + completion_tokens=100, + total_tokens=300, + reasoning_tokens=25, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=3) + ) + usage.citation_tokens = 40 + + # Test main cost calculator + prompt_cost, completion_cost_val = cost_per_token( + model="sonar-deep-research", + custom_llm_provider="perplexity", + usage_object=usage + ) + + # Calculate expected costs + expected_prompt_cost = (200 * 2e-6) + (40 * 2e-6) # Input + citation + expected_completion_cost = (100 * 8e-6) + (25 * 3e-6) + (3 / 1000 * 0.005) # Output + reasoning + search + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost_val, expected_completion_cost, rel_tol=1e-6) + + def test_model_info_includes_custom_fields(self): + """Test that get_model_info returns the custom Perplexity cost fields.""" + model_info = get_model_info(model="sonar-deep-research", custom_llm_provider="perplexity") + + # Verify custom fields are included + required_fields = [ + "citation_cost_per_token", + "search_context_cost_per_query", + "input_cost_per_token", + "output_cost_per_token", + "output_cost_per_reasoning_token" + ] + + for field in required_fields: + assert field in model_info, f"Missing field: {field}" + assert model_info[field] is not None, f"Null value for field: {field}" + + def test_various_citation_sizes(self): + """Test cost calculation with various citation sizes.""" + config = PerplexityChatConfig() + + test_cases = [ + # (citations, expected_approximate_tokens) + (["Short"], 1), + (["This is a medium-length citation with some content"], 12), + (["Very short", "Another citation", "Third one with more text content"], 15), + ([""], 0), # Empty citation + ] + + for citations, expected_approx_tokens in test_cases: + model_response = ModelResponse() + model_response.model = "sonar-deep-research" + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + raw_response_dict = { + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, + "citations": citations + } + + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + citation_tokens = getattr(model_response.usage, "citation_tokens", 0) + + # Allow for reasonable variance in token estimation + if expected_approx_tokens == 0: + assert citation_tokens == 0 + else: + assert abs(citation_tokens - expected_approx_tokens) <= 5 + + def test_cost_calculation_with_zero_values(self): + """Test cost calculation handles zero values for custom fields correctly.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + + # Set custom fields to zero + usage.citation_tokens = 0 + usage.prompt_tokens_details = PromptTokensDetailsWrapper(web_search_requests=0) + + # Should not add any extra cost + prompt_cost, completion_cost_val = cost_per_token( + model="sonar-deep-research", + custom_llm_provider="perplexity", + usage_object=usage + ) + + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = 50 * 8e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost_val, expected_completion_cost, rel_tol=1e-6) + + def test_high_volume_cost_calculation(self): + """Test cost calculation with high token and query counts.""" + usage = Usage( + prompt_tokens=50000, + completion_tokens=25000, + total_tokens=75000, + reasoning_tokens=10000 + ) + + usage.citation_tokens = 5000 + usage.prompt_tokens_details = PromptTokensDetailsWrapper(web_search_requests=100) + + total_cost = completion_cost( + completion_response=ModelResponse(usage=usage, model="sonar-deep-research"), + custom_llm_provider="perplexity" + ) + + # Calculate expected cost + expected_prompt_cost = (50000 * 2e-6) + (5000 * 2e-6) # $0.11 + expected_completion_cost = (25000 * 8e-6) + (10000 * 3e-6) + (100 / 1000 * 0.005) # $0.23 + expected_total = expected_prompt_cost + expected_completion_cost # $0.34 + + assert math.isclose(total_cost, expected_total, rel_tol=1e-6) + assert total_cost > 0.3 # Sanity check for high-volume scenario + + def test_transformation_preserves_existing_usage_fields(self): + """Test that transformation doesn't overwrite existing standard usage fields.""" + config = PerplexityChatConfig() + + model_response = ModelResponse() + model_response.usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + reasoning_tokens=20 + ) + + # Store original values + original_prompt_tokens = model_response.usage.prompt_tokens + original_completion_tokens = model_response.usage.completion_tokens + original_total_tokens = model_response.usage.total_tokens + + raw_response_dict = { + "usage": { + "prompt_tokens": 999, # Different from original + "completion_tokens": 999, # Different from original + "total_tokens": 999, # Different from original + "num_search_queries": 3 + }, + "citations": ["Some citation"] + } + + config._enhance_usage_with_perplexity_fields(model_response, raw_response_dict) + + # Original usage fields should be preserved + assert model_response.usage.prompt_tokens == original_prompt_tokens + assert model_response.usage.completion_tokens == original_completion_tokens + assert model_response.usage.total_tokens == original_total_tokens + + # But custom fields should be added + assert hasattr(model_response.usage, "prompt_tokens_details") + assert hasattr(model_response.usage, "citation_tokens") + assert model_response.usage.prompt_tokens_details.web_search_requests == 3 + + @pytest.mark.parametrize("provider_name", ["perplexity", "PERPLEXITY", "Perplexity"]) + def test_case_insensitive_provider_matching(self, provider_name): + """Test that cost calculation works with different case variations of provider name.""" + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150 + ) + usage.citation_tokens = 10 + usage.prompt_tokens_details = PromptTokensDetailsWrapper(web_search_requests=1) + + # Should work regardless of case + prompt_cost, completion_cost_val = cost_per_token( + model="sonar-deep-research", + custom_llm_provider=provider_name.lower(), # Normalize to lowercase + usage_object=usage + ) + + # Should calculate costs correctly + expected_prompt_cost = (100 * 2e-6) + (10 * 2e-6) + expected_completion_cost = (50 * 8e-6) + (1 / 1000 * 0.005) + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-6) + assert math.isclose(completion_cost_val, expected_completion_cost, rel_tol=1e-6) \ No newline at end of file diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 6dfbb8b7e5b..8f043584c09 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -466,6 +466,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): }, "additionalProperties": False, }, + "citation_cost_per_token": {"type": "number"}, "supported_modalities": { "type": "array", "items": { From 8c5fb6f539b3fee5ea5eac3892fe7908902db4dc Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 14:32:05 -0700 Subject: [PATCH 13/45] [Feat] Enterprise - Allow dynamically disabling callbacks in request headers (#11985) * Add support for disabling callbacks via x-litellm-disable-callbacks header * add _is_callback_disabled_via_headers * add get_proxy_server_request_headers * _is_callback_disabled_via_headers * X_LITELLM_DISABLE_CALLBACKS * add EnterpriseCallbackControls * use EnterpriseCallbackControls * use CustomLoggerRegistry * use CustomLoggerRegistry * CustomLoggerRegistry * EnterpriseCallbackControls * TestEnterpriseCallbackControls * docs clean up * docs dynamic callbacks * doc fixes * fix code qa checks * fix CustomLoggerRegistry --------- Co-authored-by: Cursor Agent --- docs/my-website/docs/proxy/dynamic_logging.md | 194 ++++++++++++++++++ docs/my-website/docs/proxy/logging.md | 108 ++++++++-- docs/my-website/sidebars.js | 3 +- .../enterprise_callbacks/callback_controls.py | 66 ++++++ litellm/constants.py | 3 + .../custom_logger_registry.py | 133 ++++++++++++ litellm/litellm_core_utils/litellm_logging.py | 28 +++ .../litellm_core_utils/llm_request_utils.py | 15 ++ .../test_unit_tests_init_callbacks.py | 84 +------- .../test_callback_controls.py | 176 ++++++++++++++++ 10 files changed, 709 insertions(+), 101 deletions(-) create mode 100644 docs/my-website/docs/proxy/dynamic_logging.md create mode 100644 enterprise/litellm_enterprise/enterprise_callbacks/callback_controls.py create mode 100644 litellm/litellm_core_utils/custom_logger_registry.py create mode 100644 tests/test_litellm/enterprise/enterprise_callbacks/test_callback_controls.py diff --git a/docs/my-website/docs/proxy/dynamic_logging.md b/docs/my-website/docs/proxy/dynamic_logging.md new file mode 100644 index 00000000000..484c0c1eacb --- /dev/null +++ b/docs/my-website/docs/proxy/dynamic_logging.md @@ -0,0 +1,194 @@ +import Image from '@theme/IdealImage'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + +# Dynamic Callback Management + +:::info + +This is an enterprise feature. + +[Get started with LiteLLM Enterprise](https://www.litellm.ai/enterprise) + +::: + +LiteLLM's dynamic callback management enables teams to control logging behavior on a per-request basis without requiring central infrastructure changes. This is essential for organizations managing large-scale service ecosystems where: + +- **Teams manage their own compliance** - Services can handle sensitive data appropriately without central oversight +- **Decentralized responsibility** - Each team controls their data handling while using shared infrastructure + +You can disable callbacks by passing the `x-litellm-disable-callbacks` header with your requests, giving teams granular control over where their data is logged. + +## Quick Start + + + + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse' \ + --data '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + + + + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse,datadog' \ + --data '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + + + + +## 1. View Active Logging Callbacks + +Before disabling callbacks, you can view all currently enabled callbacks on your proxy. + +### Request + +```bash +curl --location 'http://0.0.0.0:4000/callbacks/list' \ + --header 'Authorization: Bearer sk-1234' +``` + +### Response + +```json +{ + "callbacks": [ + "langfuse", + "datadog", + "prometheus", + "slack_alerting" + ] +} +``` + +## 2. Disable a Single Callback + +Use the `x-litellm-disable-callbacks` header to disable specific callbacks for individual requests. + + + + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse' \ + --data '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + + + + +```python +import openai + +client = openai.OpenAI( + api_key="sk-1234", + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="claude-sonnet-4-20250514", + messages=[ + { + "role": "user", + "content": "what llm are you" + } + ], + extra_headers={ + "x-litellm-disable-callbacks": "langfuse" + } +) + +print(response) +``` + + + + +## 3. Disable Multiple Callbacks + +You can disable multiple callbacks by providing a comma-separated list in the header. + + + + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse,datadog,prometheus' \ + --data '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + + + + +```python +import openai + +client = openai.OpenAI( + api_key="sk-1234", + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="claude-sonnet-4-20250514", + messages=[ + { + "role": "user", + "content": "what llm are you" + } + ], + extra_headers={ + "x-litellm-disable-callbacks": "langfuse,datadog,prometheus" + } +) + +print(response) +``` + + + diff --git a/docs/my-website/docs/proxy/logging.md b/docs/my-website/docs/proxy/logging.md index 3bf5ed12300..7ec9080dfdd 100644 --- a/docs/my-website/docs/proxy/logging.md +++ b/docs/my-website/docs/proxy/logging.md @@ -56,27 +56,6 @@ components in your system, including in logging tools. ## Logging Features -### Conditional Logging by Virtual Keys, Teams - -Use this to: -1. Conditionally enable logging for some virtual keys/teams -2. Set different logging providers for different virtual keys/teams - -[👉 **Get Started** - Team/Key Based Logging](team_logging) - - -### Redacting UserAPIKeyInfo - -Redact information about the user api key (hashed token, user_id, team id, etc.), from logs. - -Currently supported for Langfuse, OpenTelemetry, Logfire, ArizeAI logging. - -```yaml -litellm_settings: - callbacks: ["langfuse"] - redact_user_api_key_info: true -``` - ### Redact Messages, Response Content @@ -172,6 +151,18 @@ curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ +### Redacting UserAPIKeyInfo + +Redact information about the user api key (hashed token, user_id, team id, etc.), from logs. + +Currently supported for Langfuse, OpenTelemetry, Logfire, ArizeAI logging. + +```yaml +litellm_settings: + callbacks: ["langfuse"] + redact_user_api_key_info: true +``` + ### Disable Message Redaction If you have `litellm.turn_on_message_logging` turned on, you can override it for specific requests by @@ -269,6 +260,81 @@ print(response) LiteLLM.Info: "no-log request, skipping logging" ``` +### ✨ Dynamically Disable specific callbacks + +:::info + +This is an enterprise feature. + +[Proceed with LiteLLM Enterprise](https://www.litellm.ai/enterprise) + +::: + +For some use cases, you may want to disable specific callbacks for a request. You can do this by passing `x-litellm-disable-callbacks: ` in the request headers. + +Send the list of callbacks to disable in the request header `x-litellm-disable-callbacks`. + + + + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse' \ + --data '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + + + + +```python +import openai + +client = openai.OpenAI( + api_key="sk-1234", + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="claude-sonnet-4-20250514", + messages=[ + { + "role": "user", + "content": "what llm are you" + } + ], + extra_headers={ + "x-litellm-disable-callbacks": "langfuse" + } +) + +print(response) +``` + + + + + +### ✨ Conditional Logging by Virtual Keys, Teams + +Use this to: +1. Conditionally enable logging for some virtual keys/teams +2. Set different logging providers for different virtual keys/teams + +[👉 **Get Started** - Team/Key Based Logging](team_logging) + + + + ## What gets logged? diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index ece07984d34..0ab4a6b3e4f 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -198,7 +198,8 @@ const sidebars = { items: [ "proxy/logging", "proxy/logging_spec", - "proxy/team_logging" + "proxy/team_logging", + "proxy/dynamic_logging" ], }, diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/callback_controls.py b/enterprise/litellm_enterprise/enterprise_callbacks/callback_controls.py new file mode 100644 index 00000000000..c4316012738 --- /dev/null +++ b/enterprise/litellm_enterprise/enterprise_callbacks/callback_controls.py @@ -0,0 +1,66 @@ +import litellm +from litellm._logging import verbose_logger +from litellm.constants import X_LITELLM_DISABLE_CALLBACKS +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.llm_request_utils import ( + get_proxy_server_request_headers, +) +from litellm.proxy._types import CommonProxyErrors + + +class EnterpriseCallbackControls: + @staticmethod + def is_callback_disabled_via_headers( + callback: litellm.CALLBACK_TYPES, litellm_params: dict + ) -> bool: + """ + Check if a callback is disabled via the x-litellm-disable-callbacks header. + + Args: + callback: The callback to check (can be string, CustomLogger instance, or callable) + litellm_params: Parameters containing proxy server request info + + Returns: + bool: True if the callback should be disabled, False otherwise + """ + from litellm.litellm_core_utils.custom_logger_registry import ( + CustomLoggerRegistry, + ) + + try: + request_headers = get_proxy_server_request_headers(litellm_params) + disabled_callbacks = request_headers.get(X_LITELLM_DISABLE_CALLBACKS, None) + verbose_logger.debug(f"Dynamically disabled callbacks from {X_LITELLM_DISABLE_CALLBACKS}: {disabled_callbacks}") + verbose_logger.debug(f"Checking if {callback} is disabled via headers. Disable callbacks from headers: {disabled_callbacks}") + if disabled_callbacks is not None: + ######################################################### + # premium user check + ######################################################### + if not EnterpriseCallbackControls._premium_user_check(): + return False + ######################################################### + disabled_callbacks = set([cb.strip().lower() for cb in disabled_callbacks.split(",")]) + if isinstance(callback, str): + if callback.lower() in disabled_callbacks: + verbose_logger.debug(f"Not logging to {callback} because it is disabled via {X_LITELLM_DISABLE_CALLBACKS}") + return True + elif isinstance(callback, CustomLogger): + # get the string name of the callback + callback_str = CustomLoggerRegistry.get_callback_str_from_class_type(callback.__class__) + if callback_str is not None and callback_str.lower() in disabled_callbacks: + verbose_logger.debug(f"Not logging to {callback_str} because it is disabled via {X_LITELLM_DISABLE_CALLBACKS}") + return True + return False + except Exception as e: + verbose_logger.debug( + f"Error checking disabled callbacks header: {str(e)}" + ) + return False + + @staticmethod + def _premium_user_check(): + from litellm.proxy.proxy_server import premium_user + if premium_user: + return True + verbose_logger.warning(f"Disabling callbacks using request headers is an enterprise feature. {CommonProxyErrors.not_premium_user.value}") + return False \ No newline at end of file diff --git a/litellm/constants.py b/litellm/constants.py index c96e1dfaba8..61eeb615069 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -693,6 +693,9 @@ PROMETHEUS_BUDGET_METRICS_REFRESH_INTERVAL_MINUTES = int( MCP_TOOL_NAME_PREFIX = "mcp_tool" MAXIMUM_TRACEBACK_LINES_TO_LOG = int(os.getenv("MAXIMUM_TRACEBACK_LINES_TO_LOG", 100)) +# Headers to control callbacks +X_LITELLM_DISABLE_CALLBACKS = "x-litellm-disable-callbacks" + ########################### LiteLLM Proxy Specific Constants ########################### ######################################################################################## MAX_SPENDLOG_ROWS_TO_QUERY = int( diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py new file mode 100644 index 00000000000..20dc2b0c903 --- /dev/null +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -0,0 +1,133 @@ +""" +Registry mapping the callback class string to the class type. + +This is used to get the class type from the callback class string. + +Example: + "datadog" -> DataDogLogger + "prometheus" -> PrometheusLogger +""" + +from litellm.integrations.agentops import AgentOps +from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook +from litellm.integrations.argilla import ArgillaLogger +from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger +from litellm.integrations.braintrust_logging import BraintrustLogger +from litellm.integrations.datadog.datadog import DataDogLogger +from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger +from litellm.integrations.deepeval import DeepEvalLogger +from litellm.integrations.galileo import GalileoObserve +from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger +from litellm.integrations.gcs_pubsub.pub_sub import GcsPubSubLogger +from litellm.integrations.humanloop import HumanloopLogger +from litellm.integrations.lago import LagoLogger +from litellm.integrations.langfuse.langfuse_prompt_management import ( + LangfusePromptManagement, +) +from litellm.integrations.langsmith import LangsmithLogger +from litellm.integrations.literal_ai import LiteralAILogger +from litellm.integrations.mlflow import MlflowLogger +from litellm.integrations.openmeter import OpenMeterLogger +from litellm.integrations.opentelemetry import OpenTelemetry +from litellm.integrations.opik.opik import OpikLogger +from litellm.integrations.prometheus import PrometheusLogger +from litellm.integrations.s3_v2 import S3Logger +from litellm.integrations.vector_stores.bedrock_vector_store import BedrockVectorStore +from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler + + +class CustomLoggerRegistry: + """ + Registry mapping the callback class string to the class type. + """ + CALLBACK_CLASS_STR_TO_CLASS_TYPE = { + "lago": LagoLogger, + "openmeter": OpenMeterLogger, + "braintrust": BraintrustLogger, + "galileo": GalileoObserve, + "langsmith": LangsmithLogger, + "literalai": LiteralAILogger, + "prometheus": PrometheusLogger, + "datadog": DataDogLogger, + "datadog_llm_observability": DataDogLLMObsLogger, + "gcs_bucket": GCSBucketLogger, + "opik": OpikLogger, + "argilla": ArgillaLogger, + "opentelemetry": OpenTelemetry, + "azure_storage": AzureBlobStorageLogger, + "humanloop": HumanloopLogger, + # OTEL compatible loggers + "logfire": OpenTelemetry, + "arize": OpenTelemetry, + "langfuse_otel": OpenTelemetry, + "arize_phoenix": OpenTelemetry, + "langtrace": OpenTelemetry, + "mlflow": MlflowLogger, + "langfuse": LangfusePromptManagement, + "otel": OpenTelemetry, + "gcs_pubsub": GcsPubSubLogger, + "anthropic_cache_control_hook": AnthropicCacheControlHook, + "agentops": AgentOps, + "bedrock_vector_store": BedrockVectorStore, + "deepeval": DeepEvalLogger, + "s3_v2": S3Logger, + "dynamic_rate_limiter": _PROXY_DynamicRateLimitHandler, + } + + try: + from litellm_enterprise.enterprise_callbacks.generic_api_callback import ( + GenericAPILogger, + ) + from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import ( + PagerDutyAlerting, + ) + from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ( + ResendEmailLogger, + ) + from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import ( + SMTPEmailLogger, + ) + + enterprise_loggers = { + "pagerduty": PagerDutyAlerting, + "generic_api": GenericAPILogger, + "resend_email": ResendEmailLogger, + "smtp_email": SMTPEmailLogger, + } + CALLBACK_CLASS_STR_TO_CLASS_TYPE.update(enterprise_loggers) + except ImportError: + pass # enterprise not installed + + @classmethod + def get_callback_str_from_class_type(cls, class_type: type) -> str | None: + """ + Get the callback string from the class type. + + Args: + class_type: The class type to find the string for + + Returns: + str: The callback string, or None if not found + """ + for callback_str, callback_class in cls.CALLBACK_CLASS_STR_TO_CLASS_TYPE.items(): + if callback_class == class_type: + return callback_str + return None + + @classmethod + def get_all_callback_strs_from_class_type(cls, class_type: type) -> list[str]: + """ + Get all callback strings that map to the same class type. + Some class types (like OpenTelemetry) have multiple string mappings. + + Args: + class_type: The class type to find all strings for + + Returns: + list: List of callback strings that map to the class type + """ + callback_strs: list[str] = [] + for callback_str, callback_class in cls.CALLBACK_CLASS_STR_TO_CLASS_TYPE.items(): + if callback_class == class_type: + callback_strs.append(callback_str) + return callback_strs \ No newline at end of file diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 4523342a091..72589d57e34 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -147,6 +147,9 @@ from .initialize_dynamic_callback_params import ( from .specialty_caches.dynamic_logging_cache import DynamicLoggingCache try: + from litellm_enterprise.enterprise_callbacks.callback_controls import ( + EnterpriseCallbackControls, + ) from litellm_enterprise.enterprise_callbacks.generic_api_callback import ( GenericAPILogger, ) @@ -174,6 +177,7 @@ except Exception as e: ResendEmailLogger = CustomLogger # type: ignore SMTPEmailLogger = CustomLogger # type: ignore PagerDutyAlerting = CustomLogger # type: ignore + EnterpriseCallbackControls = None # type: ignore EnterpriseStandardLoggingPayloadSetupVAR = None _in_memory_loggers: List[Any] = [] @@ -1217,6 +1221,14 @@ class Logging(LiteLLMLoggingBaseClass): f"no-log request, skipping logging for {event_hook} event" ) return False + + # Check for dynamically disabled callbacks via headers + if EnterpriseCallbackControls is not None and EnterpriseCallbackControls.is_callback_disabled_via_headers(callback, litellm_params): + verbose_logger.debug( + f"Callback {callback} disabled via x-litellm-disable-callbacks header for {event_hook} event" + ) + return False + return True def _update_completion_start_time(self, completion_start_time: datetime.datetime): @@ -2246,6 +2258,14 @@ class Logging(LiteLLMLoggingBaseClass): self.has_run_logging(event_type="sync_failure") for callback in callbacks: try: + litellm_params = self.model_call_details.get("litellm_params", {}) + should_run = self.should_run_callback( + callback=callback, + litellm_params=litellm_params, + event_hook="failure_handler", + ) + if not should_run: + continue if callback == "lunary" and lunaryLogger is not None: print_verbose("reaches lunary for logging error!") @@ -2427,6 +2447,14 @@ class Logging(LiteLLMLoggingBaseClass): self.has_run_logging(event_type="async_failure") for callback in callbacks: try: + litellm_params = self.model_call_details.get("litellm_params", {}) + should_run = self.should_run_callback( + callback=callback, + litellm_params=litellm_params, + event_hook="async_failure_handler", + ) + if not should_run: + continue if isinstance(callback, CustomLogger): # custom logger class await callback.async_log_failure_event( kwargs=self.model_call_details, diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index 50dbdc5536e..89f5728979f 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -66,3 +66,18 @@ def pick_cheapest_chat_models_from_llm_provider(custom_llm_provider: str, n=1): # Return the top n cheapest models return [model for model, _ in model_costs[:n]] + +def get_proxy_server_request_headers(litellm_params: Optional[dict]) -> dict: + """ + Get the `proxy_server_request` headers from the litellm_params.\ + + Use this if you want to access the request headers made to LiteLLM proxy server. + """ + if litellm_params is None: + return {} + + proxy_request_headers = ( + litellm_params.get("proxy_server_request", {}).get("headers", {}) or {} + ) + + return proxy_request_headers \ No newline at end of file diff --git a/tests/logging_callback_tests/test_unit_tests_init_callbacks.py b/tests/logging_callback_tests/test_unit_tests_init_callbacks.py index 74165ccdb7d..aff622201fb 100644 --- a/tests/logging_callback_tests/test_unit_tests_init_callbacks.py +++ b/tests/logging_callback_tests/test_unit_tests_init_callbacks.py @@ -16,48 +16,10 @@ import asyncio import logging from litellm._logging import verbose_logger from prometheus_client import REGISTRY, CollectorRegistry - -from litellm.integrations.lago import LagoLogger -from litellm.integrations.deepeval import DeepEvalLogger -from litellm.integrations.openmeter import OpenMeterLogger -from litellm.integrations.braintrust_logging import BraintrustLogger -from litellm.integrations.galileo import GalileoObserve -from litellm.integrations.langsmith import LangsmithLogger -from litellm.integrations.literal_ai import LiteralAILogger -from litellm.integrations.prometheus import PrometheusLogger -from litellm.integrations.datadog.datadog import DataDogLogger -from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger -from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger -from litellm.integrations.gcs_pubsub.pub_sub import GcsPubSubLogger -from litellm.integrations.opik.opik import OpikLogger -from litellm.integrations.opentelemetry import OpenTelemetry -from litellm.integrations.mlflow import MlflowLogger -from litellm.integrations.argilla import ArgillaLogger -from litellm.integrations.deepeval.deepeval import DeepEvalLogger -from litellm.integrations.s3_v2 import S3Logger -from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger -from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook -from litellm.integrations.vector_stores.bedrock_vector_store import BedrockVectorStore -from litellm.integrations.langfuse.langfuse_prompt_management import ( - LangfusePromptManagement, -) -from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger -from litellm.integrations.agentops import AgentOps -from litellm.integrations.humanloop import HumanloopLogger -from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler -from litellm_enterprise.enterprise_callbacks.generic_api_callback import ( - GenericAPILogger, -) -from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ( - ResendEmailLogger, -) -from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import ( - SMTPEmailLogger, -) -from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import ( - PagerDutyAlerting, -) from unittest.mock import patch +from litellm.litellm_core_utils.custom_logger_registry import ( + CustomLoggerRegistry, +) # clear prometheus collectors / registry collectors = list(REGISTRY._collector_to_names.keys()) @@ -65,43 +27,7 @@ for collector in collectors: REGISTRY.unregister(collector) ###################################### -callback_class_str_to_classType = { - "lago": LagoLogger, - "openmeter": OpenMeterLogger, - "braintrust": BraintrustLogger, - "galileo": GalileoObserve, - "langsmith": LangsmithLogger, - "literalai": LiteralAILogger, - "prometheus": PrometheusLogger, - "datadog": DataDogLogger, - "datadog_llm_observability": DataDogLLMObsLogger, - "gcs_bucket": GCSBucketLogger, - "opik": OpikLogger, - "argilla": ArgillaLogger, - "opentelemetry": OpenTelemetry, - "azure_storage": AzureBlobStorageLogger, - "humanloop": HumanloopLogger, - # OTEL compatible loggers - "logfire": OpenTelemetry, - "arize": OpenTelemetry, - "langfuse_otel": OpenTelemetry, - "arize_phoenix": OpenTelemetry, - "langtrace": OpenTelemetry, - "mlflow": MlflowLogger, - "langfuse": LangfusePromptManagement, - "otel": OpenTelemetry, - "pagerduty": PagerDutyAlerting, - "gcs_pubsub": GcsPubSubLogger, - "anthropic_cache_control_hook": AnthropicCacheControlHook, - "agentops": AgentOps, - "bedrock_vector_store": BedrockVectorStore, - "generic_api": GenericAPILogger, - "resend_email": ResendEmailLogger, - "smtp_email": SMTPEmailLogger, - "deepeval": DeepEvalLogger, - "s3_v2": S3Logger, - "langfuse_otel": OpenTelemetry, -} + expected_env_vars = { "LAGO_API_KEY": "api_key", @@ -215,7 +141,7 @@ async def use_callback_in_llm_call( await asyncio.sleep(0.5) - expected_class = callback_class_str_to_classType[callback] + expected_class = CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE[callback] if used_in == "callbacks": assert isinstance(litellm._async_success_callback[0], expected_class) diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/test_callback_controls.py b/tests/test_litellm/enterprise/enterprise_callbacks/test_callback_controls.py new file mode 100644 index 00000000000..a06911170c0 --- /dev/null +++ b/tests/test_litellm/enterprise/enterprise_callbacks/test_callback_controls.py @@ -0,0 +1,176 @@ +import unittest.mock as mock +from unittest.mock import MagicMock, patch + +import pytest + +from enterprise.litellm_enterprise.enterprise_callbacks.callback_controls import ( + EnterpriseCallbackControls, +) +from litellm.constants import X_LITELLM_DISABLE_CALLBACKS +from litellm.integrations.custom_logger import CustomLogger +from litellm.integrations.datadog.datadog import DataDogLogger +from litellm.integrations.langfuse.langfuse_prompt_management import ( + LangfusePromptManagement, +) +from litellm.integrations.s3_v2 import S3Logger + + +class TestEnterpriseCallbackControls: + + @pytest.fixture + def mock_premium_user(self): + """Fixture to mock premium user check as True""" + with patch.object(EnterpriseCallbackControls, '_premium_user_check', return_value=True): + yield + + @pytest.fixture + def mock_non_premium_user(self): + """Fixture to mock premium user check as False""" + with patch.object(EnterpriseCallbackControls, '_premium_user_check', return_value=False): + yield + + @pytest.fixture + def mock_request_headers(self): + """Fixture to mock get_proxy_server_request_headers""" + with patch('enterprise.litellm_enterprise.enterprise_callbacks.callback_controls.get_proxy_server_request_headers') as mock_headers: + yield mock_headers + + def test_callback_disabled_langfuse_string(self, mock_premium_user, mock_request_headers): + """Test that 'langfuse' string callback is disabled when specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "langfuse"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) + assert result is True + + def test_callback_disabled_langfuse_customlogger(self, mock_premium_user, mock_request_headers): + """Test that LangfusePromptManagement CustomLogger instance is disabled when 'langfuse' specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "langfuse"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + langfuse_logger = LangfusePromptManagement() + result = EnterpriseCallbackControls.is_callback_disabled_via_headers(langfuse_logger, litellm_params) + assert result is True + + def test_callback_disabled_s3_v2_string(self, mock_premium_user, mock_request_headers): + """Test that 's3_v2' string callback is disabled when specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "s3_v2"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("s3_v2", litellm_params) + assert result is True + + def test_callback_disabled_s3_v2_customlogger(self, mock_premium_user, mock_request_headers): + """Test that S3Logger CustomLogger instance is disabled when 's3_v2' specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "s3_v2"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + # Mock S3Logger to avoid async initialization issues + with patch('litellm.integrations.s3_v2.S3Logger.__init__', return_value=None): + s3_logger = S3Logger() + result = EnterpriseCallbackControls.is_callback_disabled_via_headers(s3_logger, litellm_params) + assert result is True + + def test_callback_disabled_datadog_string(self, mock_premium_user, mock_request_headers): + """Test that 'datadog' string callback is disabled when specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "datadog"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("datadog", litellm_params) + assert result is True + + def test_callback_disabled_datadog_customlogger(self, mock_premium_user, mock_request_headers): + """Test that DataDogLogger CustomLogger instance is disabled when 'datadog' specified in headers""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "datadog"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + # Mock DataDogLogger to avoid async initialization issues + with patch('litellm.integrations.datadog.datadog.DataDogLogger.__init__', return_value=None): + datadog_logger = DataDogLogger() + result = EnterpriseCallbackControls.is_callback_disabled_via_headers(datadog_logger, litellm_params) + assert result is True + + def test_multiple_callbacks_disabled(self, mock_premium_user, mock_request_headers): + """Test that multiple callbacks can be disabled with comma-separated list""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "langfuse,datadog,s3_v2"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + # Test each callback is disabled + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) is True + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("datadog", litellm_params) is True + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("s3_v2", litellm_params) is True + + # Test non-disabled callback is not disabled + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("prometheus", litellm_params) is False + + def test_callback_not_disabled_when_not_in_list(self, mock_premium_user, mock_request_headers): + """Test that callbacks not in the disabled list are not disabled""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "langfuse"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("datadog", litellm_params) + assert result is False + + def test_callback_not_disabled_when_no_header(self, mock_premium_user, mock_request_headers): + """Test that callbacks are not disabled when the header is not present""" + mock_request_headers.return_value = {} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) + assert result is False + + def test_callback_not_disabled_when_header_none(self, mock_premium_user, mock_request_headers): + """Test that callbacks are not disabled when the header value is None""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: None} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) + assert result is False + + def test_non_premium_user_cannot_disable_callbacks(self, mock_non_premium_user, mock_request_headers): + """Test that non-premium users cannot disable callbacks even with the header""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "langfuse"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) + assert result is False + + def test_case_insensitive_callback_matching(self, mock_premium_user, mock_request_headers): + """Test that callback matching is case insensitive""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "LANGFUSE,DataDog"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + # Test lowercase callbacks are disabled + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) is True + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("datadog", litellm_params) is True + + def test_whitespace_handling_in_disabled_callbacks(self, mock_premium_user, mock_request_headers): + """Test that whitespace around callback names is handled correctly""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: " langfuse , datadog , s3_v2 "} + litellm_params = {"proxy_server_request": {"url": "test"}} + + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) is True + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("datadog", litellm_params) is True + assert EnterpriseCallbackControls.is_callback_disabled_via_headers("s3_v2", litellm_params) is True + + def test_custom_logger_not_in_registry(self, mock_premium_user, mock_request_headers): + """Test that CustomLogger not in registry is not disabled""" + mock_request_headers.return_value = {X_LITELLM_DISABLE_CALLBACKS: "unknown_logger"} + litellm_params = {"proxy_server_request": {"url": "test"}} + + # Create a mock CustomLogger that's not in the registry + class UnknownLogger(CustomLogger): + pass + + unknown_logger = UnknownLogger() + result = EnterpriseCallbackControls.is_callback_disabled_via_headers(unknown_logger, litellm_params) + assert result is False + + def test_exception_handling(self, mock_premium_user, mock_request_headers): + """Test that exceptions are handled gracefully and return False""" + # Make get_proxy_server_request_headers raise an exception + mock_request_headers.side_effect = Exception("Test exception") + litellm_params = {"proxy_server_request": {"url": "test"}} + + result = EnterpriseCallbackControls.is_callback_disabled_via_headers("langfuse", litellm_params) + assert result is False From eacb4dfdef8845882b6f91f92a53a47abdafbbd1 Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:54:39 -0600 Subject: [PATCH 14/45] Add Mistral 3.2 24B to model mapping (#11926) * feat(model_prices_and_context_window.json): add mistral-small-3.2-24b-instruct model with token costs and chat mode support * fix(model_prices_and_context_window.json): update model paths to include 'openrouter' prefix for mistral-small-3.1 and 3.2 --- model_prices_and_context_window.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8d4e5e276e7..df91855d257 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -9995,7 +9995,15 @@ "mode": "chat", "supports_tool_choice": true }, - "mistralai/mistral-small-3.1-24b-instruct": { + "openrouter/mistralai/mistral-small-3.1-24b-instruct": { + "max_tokens": 32000, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "mode": "chat", + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-small-3.2-24b-instruct": { "max_tokens": 32000, "input_cost_per_token": 1e-07, "output_cost_per_token": 3e-07, From b5c48c8c2261dff0972ed4c79767f931cb07ea68 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 15:34:25 -0700 Subject: [PATCH 15/45] [Feat] Add List Callbacks API Endpoint (#11987) * add get_callbacks_by_type * add list_callbacks * fix _get_callback_string * add callback_management_endpoints_router * fix proxy config.yaml * fixes list callbacks * TestCallbackManagementEndpoints * update docs * docs Response Fields * docs header format * docs Dynamic Callback Management --- docs/my-website/docs/proxy/dynamic_logging.md | 140 ++++++------ .../logging_callback_manager.py | 63 ++++++ ...odel_prices_and_context_window_backup.json | 8 +- .../callback_management_endpoints.py | 27 +++ litellm/proxy/proxy_config.yaml | 5 +- litellm/proxy/proxy_server.py | 4 + litellm/types/utils.py | 5 + .../test_callback_management_endpoints.py | 202 ++++++++++++++++++ 8 files changed, 389 insertions(+), 65 deletions(-) create mode 100644 litellm/proxy/management_endpoints/callback_management_endpoints.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py diff --git a/docs/my-website/docs/proxy/dynamic_logging.md b/docs/my-website/docs/proxy/dynamic_logging.md index 484c0c1eacb..3bc9f72b033 100644 --- a/docs/my-website/docs/proxy/dynamic_logging.md +++ b/docs/my-website/docs/proxy/dynamic_logging.md @@ -7,7 +7,7 @@ import TabItem from '@theme/TabItem'; :::info -This is an enterprise feature. +✨ This is an enterprise feature. [Get started with LiteLLM Enterprise](https://www.litellm.ai/enterprise) @@ -20,74 +20,61 @@ LiteLLM's dynamic callback management enables teams to control logging behavior You can disable callbacks by passing the `x-litellm-disable-callbacks` header with your requests, giving teams granular control over where their data is logged. -## Quick Start +## Getting Started: List and Disable Callbacks - - +Managing callbacks is a two-step process: + +1. **First, list your active callbacks** to see what's currently enabled +2. **Then, disable specific callbacks** as needed for your requests + + + +## 1. List Active Callbacks + +Start by viewing all currently enabled callbacks on your proxy to see what's available to disable. + +#### Request ```bash -curl --location 'http://0.0.0.0:4000/chat/completions' \ - --header 'Content-Type: application/json' \ - --header 'Authorization: Bearer sk-1234' \ - --header 'x-litellm-disable-callbacks: langfuse' \ - --data '{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": "what llm are you" - } - ] -}' +curl -X 'GET' \ + 'http://localhost:4000/callbacks/list' \ + -H 'accept: application/json' \ + -H 'x-litellm-api-key: sk-1234' ``` - - - -```bash -curl --location 'http://0.0.0.0:4000/chat/completions' \ - --header 'Content-Type: application/json' \ - --header 'Authorization: Bearer sk-1234' \ - --header 'x-litellm-disable-callbacks: langfuse,datadog' \ - --data '{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": "what llm are you" - } - ] -}' -``` - - - - -## 1. View Active Logging Callbacks - -Before disabling callbacks, you can view all currently enabled callbacks on your proxy. - -### Request - -```bash -curl --location 'http://0.0.0.0:4000/callbacks/list' \ - --header 'Authorization: Bearer sk-1234' -``` - -### Response +#### Response ```json { - "callbacks": [ - "langfuse", - "datadog", - "prometheus", - "slack_alerting" - ] + "success": [ + "deployment_callback_on_success", + "sync_deployment_callback_on_success" + ], + "failure": [ + "async_deployment_callback_on_failure", + "deployment_callback_on_failure" + ], + "success_and_failure": [ + "langfuse", + "datadog" + ] } ``` -## 2. Disable a Single Callback +#### Response Fields + +The response contains three arrays that categorize your active callbacks: +- **`success`** - Callbacks that only execute when requests complete successfully. These callbacks receive data from successful LLM responses. +- **`failure`** - Callbacks that only execute when requests fail or encounter errors. These callbacks receive error information and failed request data. +- **`success_and_failure`** - Callbacks that execute for both successful and failed requests. These are typically logging/observability tools that need to capture all request data regardless of outcome. + +--- + +## 2. Disable Callbacks + +Now that you know which callbacks are active, you can selectively disable them using the `x-litellm-disable-callbacks` header. You can reference any callback name from the list response above. + +### Disable a Single Callback Use the `x-litellm-disable-callbacks` header to disable specific callbacks for individual requests. @@ -140,9 +127,9 @@ print(response) -## 3. Disable Multiple Callbacks +### Disable Multiple Callbacks -You can disable multiple callbacks by providing a comma-separated list in the header. +You can disable multiple callbacks by providing a comma-separated list in the header. Use any combination of callback names from your `/callbacks/list` response. @@ -192,3 +179,36 @@ print(response) + +## Header Format and Case Sensitivity + +### Expected Header Format + +The `x-litellm-disable-callbacks` header accepts callback names in the following formats (use the exact names returned by `/callbacks/list`): + +- **Single callback**: `x-litellm-disable-callbacks: langfuse` +- **Multiple callbacks**: `x-litellm-disable-callbacks: langfuse,datadog,prometheus` + +When specifying multiple callbacks, use comma-separated values without spaces around the commas. + +### Case Sensitivity + +**Callback name checks are case insensitive.** This means all of the following are equivalent: + +```bash +# These are all equivalent +x-litellm-disable-callbacks: langfuse +x-litellm-disable-callbacks: LANGFUSE +x-litellm-disable-callbacks: LangFuse +x-litellm-disable-callbacks: langFUSE +``` + +This applies to both single and multiple callback specifications: + +```bash +# Case insensitive for multiple callbacks +x-litellm-disable-callbacks: LANGFUSE,datadog,PROMETHEUS +x-litellm-disable-callbacks: langfuse,DATADOG,prometheus +``` + + diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py index e1bddc65497..ab6c2ac7659 100644 --- a/litellm/litellm_core_utils/logging_callback_manager.py +++ b/litellm/litellm_core_utils/logging_callback_manager.py @@ -4,6 +4,7 @@ import litellm from litellm._logging import verbose_logger from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils from litellm.integrations.custom_logger import CustomLogger +from litellm.types.utils import CallbacksByType class LoggingCallbackManager: @@ -275,3 +276,65 @@ class LoggingCallbackManager: isinstance(callback, callback_type) for callback in self._get_all_callbacks() ) + + def get_callbacks_by_type(self) -> CallbacksByType: + """ + Get all active callbacks categorized by their type (success, failure, success_and_failure). + + Returns: + CallbacksByType: Dict with keys 'success', 'failure', 'success_and_failure' containing lists of callback strings + """ + # Get callback lists + success_callbacks = set(litellm.success_callback + litellm._async_success_callback) + failure_callbacks = set(litellm.failure_callback + litellm._async_failure_callback) + general_callbacks = set(litellm.callbacks) + + # Get all unique callbacks + all_callbacks = success_callbacks | failure_callbacks | general_callbacks + + result: CallbacksByType = CallbacksByType( + success=[], + failure=[], + success_and_failure=[] + ) + + for callback in all_callbacks: + callback_str = self._get_callback_string(callback) + + is_in_success = callback in success_callbacks + is_in_failure = callback in failure_callbacks + is_in_general = callback in general_callbacks + + if is_in_general or (is_in_success and is_in_failure): + result["success_and_failure"].append(callback_str) + elif is_in_success: + result["success"].append(callback_str) + elif is_in_failure: + result["failure"].append(callback_str) + + + + # final de-duplication + result["success"] = list(set(result["success"])) + result["failure"] = list(set(result["failure"])) + result["success_and_failure"] = list(set(result["success_and_failure"])) + + return result + + def _get_callback_string( + self, + callback: Union[CustomLogger, Callable, str] + ) -> str: + from litellm.litellm_core_utils.custom_logger_registry import ( + CustomLoggerRegistry, + ) + """Convert a callback to its string representation""" + if isinstance(callback, str): + return callback + elif isinstance(callback, CustomLogger): + # Try to get the string representation from the registry + callback_str = CustomLoggerRegistry.get_callback_str_from_class_type(type(callback)) + return callback_str if callback_str is not None else type(callback).__name__ + elif callable(callback): + return getattr(callback, '__name__', str(callback)) + return str(callback) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 023fc3f935a..8d4e5e276e7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13648,10 +13648,10 @@ "output_cost_per_token": 8e-06, "output_cost_per_reasoning_token": 3e-06, "citation_cost_per_token": 2e-06, - "search_queries_cost_per_query": { - "search_queries_size_low": 0.005, - "search_queries_size_medium": 0.005, - "search_queries_size_high": 0.005 + "search_context_cost_per_query": { + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005, + "search_context_size_high": 0.005 }, "litellm_provider": "perplexity", "mode": "chat", diff --git a/litellm/proxy/management_endpoints/callback_management_endpoints.py b/litellm/proxy/management_endpoints/callback_management_endpoints.py new file mode 100644 index 00000000000..3f611715e41 --- /dev/null +++ b/litellm/proxy/management_endpoints/callback_management_endpoints.py @@ -0,0 +1,27 @@ +""" +Endpoints for managing callbacks +""" +from fastapi import APIRouter, Depends + +from litellm.litellm_core_utils.logging_callback_manager import CallbacksByType +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + +router = APIRouter() + + +@router.get( + "/callbacks/list", + tags=["Logging Callbacks"], + dependencies=[Depends(user_api_key_auth)], + response_model=CallbacksByType, +) +async def list_callbacks(): + """ + View List of Active Logging Callbacks + """ + from litellm import logging_callback_manager + + # Get callbacks organized by type using the callback manager utility + callbacks_by_type = logging_callback_manager.get_callbacks_by_type() + + return callbacks_by_type \ No newline at end of file diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 4e7aa675f08..4550c1760a8 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -15,4 +15,7 @@ mcp_servers: general_settings: store_model_in_db: true - store_prompts_in_spend_logs: true \ No newline at end of file + store_prompts_in_spend_logs: true + +litellm_settings: + callbacks: ["langfuse", "datadog"] \ No newline at end of file diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 375833b7de5..95173d1c092 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -231,6 +231,9 @@ from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.proxy.management_endpoints.budget_management_endpoints import ( router as budget_management_router, ) +from litellm.proxy.management_endpoints.callback_management_endpoints import ( + router as callback_management_endpoints_router, +) from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_endpoints.customer_endpoints import ( router as customer_router, @@ -8595,6 +8598,7 @@ app.include_router(spend_management_router) app.include_router(caching_router) app.include_router(analytics_router) app.include_router(guardrails_router) +app.include_router(callback_management_endpoints_router) app.include_router(debugging_endpoints_router) app.include_router(ui_crud_endpoints_router) app.include_router(openai_files_router) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 14be87704fb..74a1422cdbf 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2489,3 +2489,8 @@ class DynamicPromptManagementParamLiteral(str, Enum): @classmethod def list_all_params(cls): return [param.value for param in cls] + +class CallbacksByType(TypedDict): + success: List[str] + failure: List[str] + success_and_failure: List[str] diff --git a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py new file mode 100644 index 00000000000..bb622304ef0 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py @@ -0,0 +1,202 @@ +import json +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert( + 0, os.path.abspath("../../../..") +) # + +from typing import cast + +import litellm +from litellm.integrations.datadog.datadog import DataDogLogger +from litellm.integrations.langfuse.langfuse import LangFuseLogger +from litellm.proxy.management_endpoints.callback_management_endpoints import router +from litellm.proxy.proxy_server import app + + +class TestCallbackManagementEndpoints: + """Test suite for callback management endpoints""" + + @pytest.fixture(autouse=True) + def setup_and_teardown(self): + """Setup and teardown for each test""" + # Reset callbacks before each test + litellm.success_callback = [] + litellm.failure_callback = [] + litellm._async_success_callback = [] + litellm._async_failure_callback = [] + litellm.callbacks = [] + + yield + + # Clean up after each test + litellm.success_callback = [] + litellm.failure_callback = [] + litellm._async_success_callback = [] + litellm._async_failure_callback = [] + litellm.callbacks = [] + + def test_list_callbacks_no_active_callbacks(self): + """Test /callbacks/list endpoint with no active callbacks""" + # Setup test client + client = TestClient(app) + + # Make request to list callbacks endpoint + response = client.get( + "/callbacks/list", + headers={"Authorization": "Bearer sk-1234"} + ) + + # Verify response + assert response.status_code == 200 + + response_data = response.json() + assert "success" in response_data + assert "failure" in response_data + assert "success_and_failure" in response_data + + # All lists should be empty + assert response_data["success"] == [] + assert response_data["failure"] == [] + assert response_data["success_and_failure"] == [] + + @patch.dict(os.environ, { + "LANGFUSE_PUBLIC_KEY": "test_public_key", + "LANGFUSE_SECRET_KEY": "test_secret_key", + "LANGFUSE_HOST": "https://test.langfuse.com" + }) + def test_list_callbacks_with_langfuse_logger(self): + """Test /callbacks/list endpoint with real Langfuse logger initialized""" + # Setup test client + client = TestClient(app) + + # Initialize Langfuse logger and add to callbacks + with patch('litellm.integrations.langfuse.langfuse.Langfuse') as mock_langfuse: + # Mock the Langfuse client initialization + mock_langfuse_client = MagicMock() + mock_langfuse.return_value = mock_langfuse_client + + + # Add string representation to callback lists (this is how the system typically works) + litellm.success_callback.append("langfuse") + litellm._async_success_callback.append("langfuse") + + # Make request to list callbacks endpoint + response = client.get( + "/callbacks/list", + headers={"Authorization": "Bearer sk-1234"} + ) + + # Verify response + assert response.status_code == 200 + + response_data = response.json() + + # Verify langfuse appears in success callbacks + assert "langfuse" in response_data["success"] + assert response_data["failure"] == [] + assert response_data["success_and_failure"] == [] + + # Verify the response structure is correct + assert isinstance(response_data["success"], list) + assert isinstance(response_data["failure"], list) + assert isinstance(response_data["success_and_failure"], list) + + def test_list_callbacks_with_datadog_logger(self): + """Test /callbacks/list endpoint with DataDog logger configuration""" + # Setup test client + client = TestClient(app) + + # Test with datadog callbacks added directly (without initializing the logger to avoid async issues) + # Add string representations to different callback types to test comprehensive categorization + litellm.success_callback.append("datadog") + litellm.failure_callback.append("datadog") + litellm.callbacks.append("datadog") + + # Make request to list callbacks endpoint + response = client.get( + "/callbacks/list", + headers={"Authorization": "Bearer sk-1234"} + ) + + # Verify response + assert response.status_code == 200 + + response_data = response.json() + + # Verify datadog appears in the correct categorization + # Since datadog is in both success and failure, it should appear in success_and_failure + assert "datadog" in response_data["success_and_failure"] + + # The categorization logic should deduplicate properly + assert len([cb for cb in response_data["success"] if cb == "datadog"]) <= 1 + assert len([cb for cb in response_data["failure"] if cb == "datadog"]) <= 1 + assert len([cb for cb in response_data["success_and_failure"] if cb == "datadog"]) <= 1 + + # Verify the response structure is correct + assert isinstance(response_data["success"], list) + assert isinstance(response_data["failure"], list) + assert isinstance(response_data["success_and_failure"], list) + + def test_list_callbacks_mixed_callback_types(self): + """Test /callbacks/list endpoint with mixed callback types (string and logger instances)""" + # Setup test client + client = TestClient(app) + + # Setup mixed callbacks + litellm.success_callback.append("langfuse") + litellm.failure_callback.append("datadog") + litellm.callbacks.append("prometheus") + + # Make request to list callbacks endpoint + response = client.get( + "/callbacks/list", + headers={"Authorization": "Bearer sk-1234"} + ) + + # Verify response + assert response.status_code == 200 + + response_data = response.json() + + # Verify callbacks are properly categorized + assert "prometheus" in response_data["success_and_failure"] # callbacks list items go to success_and_failure + assert "langfuse" in response_data["success"] + assert "datadog" in response_data["failure"] + + # Verify no duplicates + all_callbacks = ( + response_data["success"] + + response_data["failure"] + + response_data["success_and_failure"] + ) + assert len(set(all_callbacks)) == len(all_callbacks) + + + def test_list_callbacks_empty_response_structure(self): + """Test that response always has correct structure even with no callbacks""" + # Setup test client + client = TestClient(app) + + # Make request to list callbacks endpoint + response = client.get( + "/callbacks/list", + headers={"Authorization": "Bearer sk-1234"} + ) + + # Verify response structure + assert response.status_code == 200 + response_data = response.json() + + # Verify all required keys are present + required_keys = ["success", "failure", "success_and_failure"] + for key in required_keys: + assert key in response_data + assert isinstance(response_data[key], list) + From 180ed14918e1ad2947edc4c785379cbda1106352 Mon Sep 17 00:00:00 2001 From: hsuyuming Date: Mon, 23 Jun 2025 19:11:06 -0600 Subject: [PATCH 16/45] fix: fix test_get_azure_ad_token_with_oidc_token testcase issue, because (#11988) CI/CD pipeline setup AZURE_CLIENT_SECRET within enviroment variable, so we need to setup as None in this case --- tests/test_litellm/llms/azure/test_azure_common_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/llms/azure/test_azure_common_utils.py b/tests/test_litellm/llms/azure/test_azure_common_utils.py index c3eab6e1578..0ae04c357e7 100644 --- a/tests/test_litellm/llms/azure/test_azure_common_utils.py +++ b/tests/test_litellm/llms/azure/test_azure_common_utils.py @@ -1237,6 +1237,7 @@ def test_get_azure_ad_token_with_oidc_token(setup_mocks): azure_scope="test-azure-scope", # Ensure no other auth methods are available azure_ad_token_provider=None, + client_secret=None ) # Call the function From 77813ebdf80dcab95223ed1ae92d3ccb83883e2b Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 18:18:36 -0700 Subject: [PATCH 17/45] ui - fix linting error --- litellm/model_prices_and_context_window_backup.json | 10 +++++++++- .../src/components/mcp_tools/columns.tsx | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 8d4e5e276e7..df91855d257 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -9995,7 +9995,15 @@ "mode": "chat", "supports_tool_choice": true }, - "mistralai/mistral-small-3.1-24b-instruct": { + "openrouter/mistralai/mistral-small-3.1-24b-instruct": { + "max_tokens": 32000, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "mode": "chat", + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-small-3.2-24b-instruct": { "max_tokens": 32000, "input_cost_per_token": 1e-07, "output_cost_per_token": 3e-07, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx index 701aef04fad..92ebd23ebb9 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/columns.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { MCPTool, InputSchema } from "./types"; import { Button, Callout, Icon } from "@tremor/react" -const AuthBanner = ({needsAuth, authValue}) => { +const AuthBanner = ({needsAuth, authValue}: {needsAuth: boolean, authValue?: string | null}) => { if(!needsAuth || (needsAuth && authValue)) { return ( Date: Mon, 23 Jun 2025 18:34:48 -0700 Subject: [PATCH 18/45] test_azure_common_utils.py --- .../llms/azure/test_azure_common_utils.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/llms/azure/test_azure_common_utils.py b/tests/test_litellm/llms/azure/test_azure_common_utils.py index 0ae04c357e7..8c5fd7e5e08 100644 --- a/tests/test_litellm/llms/azure/test_azure_common_utils.py +++ b/tests/test_litellm/llms/azure/test_azure_common_utils.py @@ -12,13 +12,22 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm from litellm.llms.azure.common_utils import BaseAzureLLM, get_azure_ad_token -from litellm.types.utils import CallTypes from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import CallTypes # Mock the necessary dependencies @pytest.fixture -def setup_mocks(): +def setup_mocks(monkeypatch): + # Clear Azure environment variables that might interfere with tests + monkeypatch.delenv("AZURE_USERNAME", raising=False) + monkeypatch.delenv("AZURE_PASSWORD", raising=False) + monkeypatch.delenv("AZURE_CLIENT_SECRET", raising=False) + monkeypatch.delenv("AZURE_CLIENT_ID", raising=False) + monkeypatch.delenv("AZURE_TENANT_ID", raising=False) + monkeypatch.delenv("AZURE_SCOPE", raising=False) + monkeypatch.delenv("AZURE_AD_TOKEN", raising=False) + with patch( "litellm.llms.azure.common_utils.get_azure_ad_token_from_entra_id" ) as mock_entra_token, patch( @@ -1224,11 +1233,16 @@ def test_get_azure_ad_token_with_username_password_from_env(setup_mocks, monkeyp assert token == "mock-username-password-token" -def test_get_azure_ad_token_with_oidc_token(setup_mocks): +def test_get_azure_ad_token_with_oidc_token(setup_mocks, monkeypatch): """Test get_azure_ad_token with OIDC token.""" # Reset mocks to ensure clean state setup_mocks["oidc_token"].reset_mock() + # Clear environment variables that might interfere with OIDC token logic + monkeypatch.delenv("AZURE_USERNAME", raising=False) + monkeypatch.delenv("AZURE_PASSWORD", raising=False) + monkeypatch.delenv("AZURE_CLIENT_SECRET", raising=False) + # Create test parameters with OIDC token, client_id, and tenant_id litellm_params = GenericLiteLLMParams( azure_ad_token="oidc/test-token", @@ -1237,7 +1251,9 @@ def test_get_azure_ad_token_with_oidc_token(setup_mocks): azure_scope="test-azure-scope", # Ensure no other auth methods are available azure_ad_token_provider=None, - client_secret=None + client_secret=None, + azure_username=None, + azure_password=None, ) # Call the function @@ -1258,9 +1274,13 @@ def test_get_azure_ad_token_with_oidc_token(setup_mocks): assert token == "mock-oidc-token" -def test_get_azure_ad_token_with_token_refresh(setup_mocks): +def test_get_azure_ad_token_with_token_refresh(setup_mocks, monkeypatch): """Test get_azure_ad_token with token refresh enabled.""" # Reset mocks to ensure clean state + monkeypatch.delenv("AZURE_USERNAME", raising=False) + monkeypatch.delenv("AZURE_PASSWORD", raising=False) + monkeypatch.delenv("AZURE_CLIENT_SECRET", raising=False) + setup_mocks["token_provider"].reset_mock() # Enable token refresh From 321fc09b3e9187ce5ddd5c648118d903f694d847 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 18:43:56 -0700 Subject: [PATCH 19/45] ui fix linting errors --- .../src/components/mcp_tools/mcp_tools.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index eb45ae21461..26cd758678d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -89,12 +89,18 @@ export const AuthModal = ({ ); }; -const AuthSection = ({ authType, onAuthSubmit }) => { +const AuthSection = ({ + authType, + onAuthSubmit +}: { + authType: string | null | undefined; + onAuthSubmit: (value: string) => void; +}) => { const [modalVisible, setModalVisible] = useState(false); const handleAddAuth = () => setModalVisible(true); - const handleModalOk = (authValue) => { + const handleModalOk = (authValue: string) => { onAuthSubmit(authValue); setModalVisible(false); }; @@ -239,7 +245,7 @@ const MCPToolsViewer = ({ {mcpServerHasAuth(auth_type) && ( { + onAuthSubmit={(value: string) => { setMcpAuthValue(value); }} /> From 5534387c223674610e88672c4fcc973ae25c3669 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 19:34:55 -0700 Subject: [PATCH 20/45] =?UTF-8?q?bump:=20version=201.73.0=20=E2=86=92=201.?= =?UTF-8?q?73.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 af13459b70f..b83bf9c604d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm" -version = "1.73.0" +version = "1.73.1" description = "Library to easily interface with LLM API providers" authors = ["BerriAI"] license = "MIT" @@ -141,7 +141,7 @@ requires = ["poetry-core", "wheel"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "1.73.0" +version = "1.73.1" version_files = [ "pyproject.toml:^version" ] From d4ba4900cf5c4328d8dc62ffd5f668bff9532c93 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 19:36:31 -0700 Subject: [PATCH 21/45] bump litellm enterprise --- .../litellm_enterprise-0.1.9-py3-none-any.whl | Bin 0 -> 92729 bytes enterprise/dist/litellm_enterprise-0.1.9.tar.gz | Bin 0 -> 33608 bytes enterprise/pyproject.toml | 4 ++-- poetry.lock | 6 +++--- pyproject.toml | 2 +- requirements.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 enterprise/dist/litellm_enterprise-0.1.9-py3-none-any.whl create mode 100644 enterprise/dist/litellm_enterprise-0.1.9.tar.gz diff --git a/enterprise/dist/litellm_enterprise-0.1.9-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.9-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..eb4b9d1083b4744dd719d95bbb7fccea1a1287b4 GIT binary patch literal 92729 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

Syb!pnLZiJsQFPYGR<|Sr#rLM1H)BU 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 Lg2)>`>B0JcDMa6= literal 0 HcmV?d00001 diff --git a/enterprise/dist/litellm_enterprise-0.1.9.tar.gz b/enterprise/dist/litellm_enterprise-0.1.9.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..748ed2150efd2080071b9f7792c0362eb31e8fec GIT binary patch literal 33608 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

a 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.27.8)"] [[package]] name = "litellm-enterprise" -version = "0.1.8" +version = "0.1.9" description = "Package for LiteLLM Enterprise features" optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "litellm_enterprise-0.1.8.tar.gz", hash = "sha256:59b8c8a18f51c1e5fe2e341d66ba268c2814c8035825ae929530438524dc08ca"}, + {file = "litellm_enterprise-0.1.9.tar.gz", hash = "sha256:2bdf629cf8bd36805bad70acb609bfa0c00eaf72d3b42f6e17c54c5b50758c4a"}, ] [[package]] @@ -4991,4 +4991,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.8.1,<4.0, !=3.9.7" -content-hash = "1d0d0c73c55208694ca58a8a8d12ee0e95e265bb16aefd72d79ca8b5e49a8bca" +content-hash = "c3d56e337720ab9c5ab2ec87794f16d1ba831ee86edeb236ae7429f3a7717677" diff --git a/pyproject.toml b/pyproject.toml index b83bf9c604d..d8523d12840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3. mcp = {version = "1.9.3", optional = true, python = ">=3.10"} litellm-proxy-extras = {version = "0.2.5", optional = true} rich = {version = "13.7.1", optional = true} -litellm-enterprise = {version = "0.1.8", optional = true} +litellm-enterprise = {version = "0.1.9", optional = true} diskcache = {version = "^5.6.1", optional = true} [tool.poetry.extras] diff --git a/requirements.txt b/requirements.txt index c5b18f1b0d8..905a41fd4a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,4 +56,4 @@ websockets==13.1.0 # for realtime API ######################## # LITELLM ENTERPRISE DEPENDENCIES ######################## -litellm-enterprise==0.1.8 +litellm-enterprise==0.1.9 From d677a6bda2c5971986765bf194c731b952a320ca Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 19:59:44 -0700 Subject: [PATCH 22/45] [Bug Fix] Bedrock Guardrail - Don't raise exception on intervene action (#11875) * Refactor Bedrock guardrails to handle BLOCKED vs ANONYMIZED actions * Enhance Bedrock guardrail masking with improved anonymization logic * fix _should_raise_guardrail_blocked_exception * test_bedrock_guardrail_uses_masked_output_without_masking_flags * fix linting error --------- Co-authored-by: Cursor Agent --- .../guardrail_hooks/bedrock_guardrails.py | 131 ++++++--- .../test_bedrock_guardrails.py | 251 +++++++++++++++++- 2 files changed, 341 insertions(+), 41 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index fb8e9236f48..39b6fe9a25e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -79,9 +79,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): if messages: for message in messages: - message_text_content: Optional[ - List[str] - ] = self.get_content_for_message(message=message) + message_text_content: Optional[List[str]] = ( + self.get_content_for_message(message=message) + ) if message_text_content is None: continue for text_content in message_text_content: @@ -241,7 +241,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self, response: BedrockGuardrailResponse ) -> bool: """ - By default always raise an exception when a guardrail intervention is detected. + Only raise exception for "BLOCKED" actions, not for "ANONYMIZED" actions. If `self.mask_request_content` or `self.mask_response_content` is set to `True`, then use the output from the guardrail to mask the request or response content. """ @@ -250,11 +250,68 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): if self.mask_request_content or self.mask_response_content: return False - # if intervention, return True - if response.get("action") == "GUARDRAIL_INTERVENED": - return True - # if no intervention, return False + if response.get("action") != "GUARDRAIL_INTERVENED": + return False + + # Check assessments to determine if any actions were BLOCKED (vs ANONYMIZED) + assessments = response.get("assessments", []) + if not assessments: + return False + + for assessment in assessments: + # Check topic policy + topic_policy = assessment.get("topicPolicy") + if topic_policy: + topics = topic_policy.get("topics", []) + for topic in topics: + if topic.get("action") == "BLOCKED": + return True + + # Check content policy + content_policy = assessment.get("contentPolicy") + if content_policy: + filters = content_policy.get("filters", []) + for filter_item in filters: + if filter_item.get("action") == "BLOCKED": + return True + + # Check word policy + word_policy = assessment.get("wordPolicy") + if word_policy: + custom_words = word_policy.get("customWords", []) + for custom_word in custom_words: + if custom_word.get("action") == "BLOCKED": + return True + managed_words = word_policy.get("managedWordLists", []) + for managed_word in managed_words: + if managed_word.get("action") == "BLOCKED": + return True + + # Check sensitive information policy + sensitive_info_policy = assessment.get("sensitiveInformationPolicy") + if sensitive_info_policy: + pii_entities = sensitive_info_policy.get("piiEntities", []) + if pii_entities: + for pii_entity in pii_entities: + if pii_entity.get("action") == "BLOCKED": + return True + regexes = sensitive_info_policy.get("regexes", []) + if regexes: + for regex in regexes: + if regex.get("action") == "BLOCKED": + return True + + # Check contextual grounding policy + contextual_grounding_policy = assessment.get("contextualGroundingPolicy") + if contextual_grounding_policy: + grounding_filters = contextual_grounding_policy.get("filters", []) + for filter_item in grounding_filters: + if filter_item.get("action") == "BLOCKED": + return True + + # If we got here, intervention occurred but no BLOCKED actions found + # This means all actions were ANONYMIZED or NONE, so don't raise exception return False @log_guardrail_information @@ -300,11 +357,11 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 2. Update the messages with the guardrail response ########## ######################################################### - data[ - "messages" - ] = self._update_messages_with_updated_bedrock_guardrail_response( - messages=new_messages, - bedrock_guardrail_response=bedrock_guardrail_response, + data["messages"] = ( + self._update_messages_with_updated_bedrock_guardrail_response( + messages=new_messages, + bedrock_guardrail_response=bedrock_guardrail_response, + ) ) ######################################################### @@ -354,11 +411,11 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 2. Update the messages with the guardrail response ########## ######################################################### - data[ - "messages" - ] = self._update_messages_with_updated_bedrock_guardrail_response( - messages=new_messages, - bedrock_guardrail_response=bedrock_guardrail_response, + data["messages"] = ( + self._update_messages_with_updated_bedrock_guardrail_response( + messages=new_messages, + bedrock_guardrail_response=bedrock_guardrail_response, + ) ) ######################################################### @@ -408,11 +465,11 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 2. Update the messages with the guardrail response ########## ######################################################### - data[ - "messages" - ] = self._update_messages_with_updated_bedrock_guardrail_response( - messages=new_messages, - bedrock_guardrail_response=bedrock_guardrail_response, + data["messages"] = ( + self._update_messages_with_updated_bedrock_guardrail_response( + messages=new_messages, + bedrock_guardrail_response=bedrock_guardrail_response, + ) ) ######################################################### @@ -440,21 +497,29 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): Returns: List of messages with content masked according to guardrail response """ - # Skip processing if masking is not enabled - if not (self.mask_request_content or self.mask_response_content): - return messages - # Get masked texts from guardrail response masked_texts = self._extract_masked_texts_from_response( bedrock_guardrail_response ) - if not masked_texts: - return messages - # Apply masking to messages using index tracking - return self._apply_masking_to_messages( - messages=messages, masked_texts=masked_texts - ) + # If guardrail provided masked output, use it regardless of masking flags + # because the guardrail has already determined this content needs anonymization + if masked_texts: + verbose_proxy_logger.debug( + "Bedrock guardrail provided masked output, applying to messages" + ) + return self._apply_masking_to_messages( + messages=messages, masked_texts=masked_texts + ) + + # If masking is enabled but no masked texts available, still try to apply + # (this maintains backward compatibility for edge cases) + if self.mask_request_content or self.mask_response_content: + verbose_proxy_logger.debug( + "Masking enabled but no masked output from guardrail, returning original messages" + ) + + return messages async def async_post_call_streaming_iterator_hook( self, diff --git a/tests/guardrails_tests/test_bedrock_guardrails.py b/tests/guardrails_tests/test_bedrock_guardrails.py index 998c27a93db..246338fd1d3 100644 --- a/tests/guardrails_tests/test_bedrock_guardrails.py +++ b/tests/guardrails_tests/test_bedrock_guardrails.py @@ -10,14 +10,13 @@ from litellm.caching import DualCache from unittest.mock import MagicMock, AsyncMock, patch @pytest.mark.asyncio -async def test_bedrock_guardrails(): +async def test_bedrock_guardrails_pii_masking(): # Create proper mock objects mock_user_api_key_dict = UserAPIKeyAuth() guardrail = BedrockGuardrail( guardrailIdentifier="wf0hkdb5x07f", guardrailVersion="DRAFT", - mask_request_content=True, ) request_data = { @@ -35,7 +34,7 @@ async def test_bedrock_guardrails(): user_api_key_dict=mock_user_api_key_dict, call_type="completion" ) - print(response) + print("response after moderation hook", response) if response: # Only assert if response is not None assert response["messages"][0]["content"] == "Hello, my phone number is {PHONE}" @@ -45,14 +44,13 @@ async def test_bedrock_guardrails(): @pytest.mark.asyncio -async def test_bedrock_guardrails_content_list(): +async def test_bedrock_guardrails_pii_masking_content_list(): # Create proper mock objects mock_user_api_key_dict = UserAPIKeyAuth() guardrail = BedrockGuardrail( guardrailIdentifier="wf0hkdb5x07f", guardrailVersion="DRAFT", - mask_request_content=True, ) request_data = { @@ -105,7 +103,7 @@ async def test_bedrock_guardrails_with_streaming(): ) guardrail = BedrockGuardrail( - guardrailIdentifier="wf0hkdb5x07f", + guardrailIdentifier="ff6ujrregl1q", guardrailVersion="DRAFT", supported_event_hooks=[GuardrailEventHooks.post_call], guardrail_name="bedrock-post-guard", @@ -118,7 +116,7 @@ async def test_bedrock_guardrails_with_streaming(): "messages": [ { "role": "user", - "content": "My name is ishaan@gmail.com" + "content": "Hi I like coffee" } ], "stream": True, @@ -154,7 +152,7 @@ async def test_bedrock_guardrails_with_streaming_no_violation(): ) guardrail = BedrockGuardrail( - guardrailIdentifier="wf0hkdb5x07f", + guardrailIdentifier="ff6ujrregl1q", guardrailVersion="DRAFT", supported_event_hooks=[GuardrailEventHooks.post_call], guardrail_name="bedrock-post-guard", @@ -334,4 +332,241 @@ async def test_bedrock_guardrail_aws_param_persistence(): assert kwargs["aws_secret_access_key"] == "test-secret-key" assert kwargs["aws_region_name"] == "us-east-1" +@pytest.mark.asyncio +async def test_bedrock_guardrail_blocked_vs_anonymized_actions(): + """Test that BLOCKED actions raise exceptions but ANONYMIZED actions do not""" + from unittest.mock import MagicMock + from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail + from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrailResponse + + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT" + ) + + # Test 1: ANONYMIZED action should NOT raise exception + anonymized_response: BedrockGuardrailResponse = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "Hello, my phone number is {PHONE}" + }], + "assessments": [{ + "sensitiveInformationPolicy": { + "piiEntities": [{ + "type": "PHONE", + "match": "+1 412 555 1212", + "action": "ANONYMIZED" + }] + } + }] + } + + should_raise = guardrail._should_raise_guardrail_blocked_exception(anonymized_response) + assert should_raise is False, "ANONYMIZED actions should not raise exceptions" + + # Test 2: BLOCKED action should raise exception + blocked_response: BedrockGuardrailResponse = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "I can't provide that information." + }], + "assessments": [{ + "topicPolicy": { + "topics": [{ + "name": "Sensitive Topic", + "type": "DENY", + "action": "BLOCKED" + }] + } + }] + } + + should_raise = guardrail._should_raise_guardrail_blocked_exception(blocked_response) + assert should_raise is True, "BLOCKED actions should raise exceptions" + + # Test 3: Mixed actions - should raise if ANY action is BLOCKED + mixed_response: BedrockGuardrailResponse = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "I can't provide that information." + }], + "assessments": [{ + "sensitiveInformationPolicy": { + "piiEntities": [{ + "type": "PHONE", + "match": "+1 412 555 1212", + "action": "ANONYMIZED" + }] + }, + "topicPolicy": { + "topics": [{ + "name": "Blocked Topic", + "type": "DENY", + "action": "BLOCKED" + }] + } + }] + } + + should_raise = guardrail._should_raise_guardrail_blocked_exception(mixed_response) + assert should_raise is True, "Mixed actions with any BLOCKED should raise exceptions" + + # Test 4: NONE action should not raise exception + none_response: BedrockGuardrailResponse = { + "action": "NONE", + "outputs": [], + "assessments": [] + } + + should_raise = guardrail._should_raise_guardrail_blocked_exception(none_response) + assert should_raise is False, "NONE actions should not raise exceptions" + + # Test 5: Test other policy types with BLOCKED actions + content_blocked_response: BedrockGuardrailResponse = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "I can't provide that information." + }], + "assessments": [{ + "contentPolicy": { + "filters": [{ + "type": "VIOLENCE", + "confidence": "HIGH", + "action": "BLOCKED" + }] + } + }] + } + + should_raise = guardrail._should_raise_guardrail_blocked_exception(content_blocked_response) + assert should_raise is True, "Content policy BLOCKED actions should raise exceptions" + + +@pytest.mark.asyncio +async def test_bedrock_guardrail_masking_with_anonymized_response(): + """Test that masking works correctly when guardrail returns ANONYMIZED actions""" + from unittest.mock import AsyncMock, MagicMock, patch + from litellm.proxy._types import UserAPIKeyAuth + from litellm.caching import DualCache + + # Create proper mock objects + mock_user_api_key_dict = UserAPIKeyAuth() + + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT", + mask_request_content=True, + ) + + # Mock the Bedrock API response with ANONYMIZED action + mock_bedrock_response = MagicMock() + mock_bedrock_response.status_code = 200 + mock_bedrock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "Hello, my phone number is {PHONE}" + }], + "assessments": [{ + "sensitiveInformationPolicy": { + "piiEntities": [{ + "type": "PHONE", + "match": "+1 412 555 1212", + "action": "ANONYMIZED" + }] + } + }] + } + + request_data = { + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Hello, my phone number is +1 412 555 1212"}, + ], + } + + # Patch the async_handler.post method + with patch.object(guardrail.async_handler, 'post', new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_bedrock_response + + # This should NOT raise an exception since action is ANONYMIZED + try: + response = await guardrail.async_moderation_hook( + data=request_data, + user_api_key_dict=mock_user_api_key_dict, + call_type="completion" + ) + # Should succeed and return data with masked content + assert response is not None + assert response["messages"][0]["content"] == "Hello, my phone number is {PHONE}" + except Exception as e: + pytest.fail(f"Should not raise exception for ANONYMIZED actions, but got: {e}") + + +@pytest.mark.asyncio +async def test_bedrock_guardrail_uses_masked_output_without_masking_flags(): + """Test that masked output from guardrails is used even when masking flags are not enabled""" + from unittest.mock import AsyncMock, MagicMock, patch + from litellm.proxy._types import UserAPIKeyAuth + + # Create proper mock objects + mock_user_api_key_dict = UserAPIKeyAuth() + + # Create guardrail WITHOUT masking flags enabled + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT", + # Note: No mask_request_content=True or mask_response_content=True + ) + + # Mock the Bedrock API response with ANONYMIZED action and masked output + mock_bedrock_response = MagicMock() + mock_bedrock_response.status_code = 200 + mock_bedrock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{ + "text": "Hello, my phone number is {PHONE} and email is {EMAIL}" + }], + "assessments": [{ + "sensitiveInformationPolicy": { + "piiEntities": [ + { + "type": "PHONE", + "match": "+1 412 555 1212", + "action": "ANONYMIZED" + }, + { + "type": "EMAIL", + "match": "user@example.com", + "action": "ANONYMIZED" + } + ] + } + }] + } + + request_data = { + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Hello, my phone number is +1 412 555 1212 and email is user@example.com"}, + ], + } + + # Patch the async_handler.post method + with patch.object(guardrail.async_handler, 'post', new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_bedrock_response + + # This should use the masked output even without masking flags + response = await guardrail.async_moderation_hook( + data=request_data, + user_api_key_dict=mock_user_api_key_dict, + call_type="completion" + ) + + # Should use the masked content from guardrail output + assert response is not None + assert response["messages"][0]["content"] == "Hello, my phone number is {PHONE} and email is {EMAIL}" + print("✅ Masked output was applied even without masking flags enabled") + + + \ No newline at end of file From 3d542846b24522aee6634e22c447209110e0f8d0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 20:03:26 -0700 Subject: [PATCH 23/45] test fix --- .../azure/response/test_azure_transformation.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index 605c681c4a4..c210f91170e 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -89,20 +89,3 @@ def test_validate_environment_azure_api_key_within_secret_str(): expected = {"api-key": "test-api-key"} assert result == expected - - -def test_validate_environment_get_azure_ad_token(): - azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() - - with patch( - "litellm.llms.azure.responses.transformation.get_azure_ad_token" - ) as mock_get_azure_ad_token: - mock_get_azure_ad_token.side_effect = lambda key: "test-azure-ad-token" - - litellm_params = GenericLiteLLMParams(azure_ad_token="test-azure-ad-token") - result = azure_openai_responses_apiconfig.validate_environment( - headers={}, model="", litellm_params=litellm_params - ) - expected = {"Authorization": "Bearer test-azure-ad-token"} - - assert result == expected From 5442611a0b6552454b44379ed37352f25d35aeb3 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 20:10:39 -0700 Subject: [PATCH 24/45] fix linting --- .../proxy/guardrails/guardrail_hooks/bedrock_guardrails.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 39b6fe9a25e..a16f7f1a0b2 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -306,8 +306,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): contextual_grounding_policy = assessment.get("contextualGroundingPolicy") if contextual_grounding_policy: grounding_filters = contextual_grounding_policy.get("filters", []) - for filter_item in grounding_filters: - if filter_item.get("action") == "BLOCKED": + for grounding_filter in grounding_filters: + if grounding_filter.get("action") == "BLOCKED": return True # If we got here, intervention occurred but no BLOCKED actions found From eec0e4d2be83641e73b1cd55ff01da2e16af3c28 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 23 Jun 2025 21:25:35 -0700 Subject: [PATCH 25/45] docs - web search --- docs/my-website/docs/completion/web_search.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/docs/completion/web_search.md b/docs/my-website/docs/completion/web_search.md index b0c77debe3a..fe49be852a7 100644 --- a/docs/my-website/docs/completion/web_search.md +++ b/docs/my-website/docs/completion/web_search.md @@ -8,7 +8,7 @@ Use web search with litellm | Feature | Details | |---------|---------| | Supported Endpoints | - `/chat/completions`
- `/responses` | -| Supported Providers | `openai`, `xai`, `vertex_ai`, `gemini` | +| Supported Providers | `openai`, `xai`, `vertex_ai`, `gemini`, `perplexity` | | LiteLLM Cost Tracking | ✅ Supported | | LiteLLM Version | `v1.71.0+` | From 3f53c2eb18d4308cdca90eb8c677920ccadf6e09 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Mon, 23 Jun 2025 22:24:47 -0700 Subject: [PATCH 26/45] VertexAI Anthropic passthrough cost calc fixes + Filter litellm params from request sent to passthrough endpoint (#11992) * fix(pass_through_endpoints.py): filter out litellm params from passthrough body * fix(pass_through_endpoints.py): fix metadata positioninig * fix(vertex_passthrough_logging_handler.py): consider prompt tokens for vertex anthropic passthrough logging Fixes https://github.com/BerriAI/litellm/issues/11935 * fix(utils.py): handle prompt token details existing alongside cache creation input tokens Fixes https://github.com/BerriAI/litellm/issues/11935 * fix(pass_through_endpoints.py): handle metdata update --- .../static/chunks/250-f07ccab57fe599d4.js | 2 +- .../static/chunks/webpack-a426aae3231a8df1.js | 2 +- .../out/_next/static/css/31b7f215e119031e.css | 2 +- .../_experimental/out/assets/logos/aws.svg | 68 +++---- .../out/assets/logos/cerebras.svg | 178 +++++++++--------- .../out/assets/logos/deepseek.svg | 50 ++--- .../out/assets/logos/perplexity-ai.svg | 30 +-- litellm/proxy/_experimental/out/index.html | 2 +- litellm/proxy/_experimental/out/index.txt | 2 +- litellm/proxy/_experimental/out/model_hub.txt | 2 +- .../proxy/_experimental/out/onboarding.html | 1 - .../proxy/_experimental/out/onboarding.txt | 2 +- .../vertex_passthrough_logging_handler.py | 19 +- .../pass_through_endpoints.py | 107 +++++++---- litellm/types/utils.py | 41 ++-- tests/test_litellm/test_cost_calculator.py | 59 ++++++ 16 files changed, 320 insertions(+), 247 deletions(-) delete mode 100644 litellm/proxy/_experimental/out/onboarding.html diff --git a/litellm/proxy/_experimental/out/_next/static/chunks/250-f07ccab57fe599d4.js b/litellm/proxy/_experimental/out/_next/static/chunks/250-f07ccab57fe599d4.js index 0770ae40d66..55794ed7607 100644 --- a/litellm/proxy/_experimental/out/_next/static/chunks/250-f07ccab57fe599d4.js +++ b/litellm/proxy/_experimental/out/_next/static/chunks/250-f07ccab57fe599d4.js @@ -1 +1 @@ -"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[250],{19250:function(e,t,o){o.d(t,{$D:function(){return eJ},$I:function(){return Q},$o:function(){return tF},AZ:function(){return X},Au:function(){return e_},BL:function(){return eZ},Br:function(){return b},Bw:function(){return tx},E9:function(){return eX},EB:function(){return th},EG:function(){return eW},EY:function(){return e0},Eb:function(){return N},FC:function(){return eh},Gh:function(){return eG},H1:function(){return I},H2:function(){return a},Hx:function(){return ej},I1:function(){return S},It:function(){return B},J$:function(){return ec},JO:function(){return O},K8:function(){return h},K_:function(){return eQ},Ko:function(){return tN},LY:function(){return eV},Lp:function(){return eR},MO:function(){return p},Mx:function(){return tf},N3:function(){return eb},N8:function(){return er},NL:function(){return e9},NV:function(){return m},Nc:function(){return eP},Nz:function(){return e1},O3:function(){return eD},OD:function(){return eC},OU:function(){return eu},Of:function(){return F},Og:function(){return y},Ou:function(){return ty},Ov:function(){return C},Oz:function(){return tB},PC:function(){return e6},PT:function(){return K},PY:function(){return tv},Pj:function(){return tt},Pv:function(){return tm},Qg:function(){return ex},RQ:function(){return j},Rg:function(){return et},Sb:function(){return ez},So:function(){return ea},TF:function(){return tu},Tj:function(){return e2},Tx:function(){return tO},U8:function(){return te},UM:function(){return tl},VA:function(){return A},Vt:function(){return eY},W_:function(){return M},X:function(){return es},XB:function(){return tg},XO:function(){return T},Xd:function(){return eS},Xm:function(){return x},YU:function(){return eH},Yi:function(){return tj},Yo:function(){return U},Z9:function(){return V},Zr:function(){return k},a6:function(){return P},aC:function(){return tw},ao:function(){return eK},b1:function(){return ew},cq:function(){return R},cu:function(){return eI},e2:function(){return eT},eH:function(){return W},eW:function(){return tC},eZ:function(){return eB},fE:function(){return td},fP:function(){return eo},fk:function(){return tS},g:function(){return e4},gX:function(){return eO},gl:function(){return to},h3:function(){return ed},hT:function(){return ev},hy:function(){return f},ix:function(){return Y},j2:function(){return ei},jA:function(){return e$},jE:function(){return eM},jr:function(){return tT},kK:function(){return g},kn:function(){return $},lP:function(){return w},lU:function(){return ta},lg:function(){return eN},mC:function(){return ti},mR:function(){return en},mY:function(){return tc},m_:function(){return D},mp:function(){return eq},n$:function(){return em},n9:function(){return ts},nJ:function(){return tb},nd:function(){return e7},o6:function(){return ee},oC:function(){return eF},ol:function(){return L},pf:function(){return eL},pu:function(){return tk},qI:function(){return _},qW:function(){return t_},qd:function(){return tE},qk:function(){return e8},qm:function(){return u},r1:function(){return tp},r6:function(){return G},rs:function(){return v},s0:function(){return Z},sN:function(){return eA},t$:function(){return J},t0:function(){return eE},t3:function(){return e5},tB:function(){return tn},tN:function(){return ep},u5:function(){return el},v9:function(){return ek},vh:function(){return eU},wX:function(){return E},wd:function(){return eg},xA:function(){return ey},xO:function(){return e3},xX:function(){return z},xZ:function(){return tr},zX:function(){return c},zg:function(){return ef}});var r=o(41021);let a=null;console.log=function(){};let n=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,o=window.location.origin,r=t||o;console.log("proxyBaseUrl:",a),console.log("serverRootPath:",e),e.length>0&&!r.endsWith(e)&&"/"!=e&&(r+=e,a=r),console.log("Updated proxyBaseUrl:",a)},c=()=>a||window.location.origin,s={GET:"GET",DELETE:"DELETE"},i=0,l=async e=>{let t=Date.now();t-i>6e4?(e.includes("Authentication Error - Expired Key")&&(r.ZP.info("UI Session Expired. Logging out."),i=t,document.cookie="token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;",window.location.href=window.location.pathname),i=t):console.log("Error suppressed to prevent spam:",e)},d="Authorization";function h(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"Authorization";console.log("setGlobalLitellmHeaderName: ".concat(e)),d=e}let p=async()=>{console.log("Getting UI config");let e=await fetch("/litellm/.well-known/litellm-ui-config"),t=await e.json();return console.log("jsonData in getUiConfig:",t),n(t.server_root_path,t.proxy_base_url),t},w=async()=>{let e=a?"".concat(a,"/openapi.json"):"/openapi.json",t=await fetch(e);return await t.json()},u=async e=>{try{let t=a?"".concat(a,"/get/litellm_model_cost_map"):"/get/litellm_model_cost_map",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}}),r=await o.json();return console.log("received litellm model cost data: ".concat(r)),r}catch(e){throw console.error("Failed to get model cost map:",e),e}},g=async(e,t)=>{try{let o=a?"".concat(a,"/model/new"):"/model/new",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text()||"Network response was not ok";throw r.ZP.error(e),Error(e)}let c=await n.json();return console.log("API Response:",c),r.ZP.destroy(),r.ZP.success("Model ".concat(t.model_name," created successfully"),2),c}catch(e){throw console.error("Failed to create key:",e),e}},f=async e=>{try{let t=a?"".concat(a,"/model/settings"):"/model/settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){console.error("Failed to get model settings:",e)}},y=async(e,t)=>{console.log("model_id in model delete call: ".concat(t));try{let o=a?"".concat(a,"/model/delete"):"/model/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},m=async(e,t)=>{if(console.log("budget_id in budget delete call: ".concat(t)),null!=e)try{let o=a?"".concat(a,"/budget/delete"):"/budget/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},k=async(e,t)=>{try{console.log("Form Values in budgetCreateCall:",t),console.log("Form Values after check:",t);let o=a?"".concat(a,"/budget/new"):"/budget/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},_=async(e,t)=>{try{console.log("Form Values in budgetUpdateCall:",t),console.log("Form Values after check:",t);let o=a?"".concat(a,"/budget/update"):"/budget/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},T=async(e,t)=>{try{let o=a?"".concat(a,"/invitation/new"):"/invitation/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},j=async e=>{try{let t=a?"".concat(a,"/alerting/settings"):"/alerting/settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},E=async(e,t,o)=>{try{if(console.log("Form Values in keyCreateCall:",o),o.description&&(o.metadata||(o.metadata={}),o.metadata.description=o.description,delete o.description,o.metadata=JSON.stringify(o.metadata)),o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}console.log("Form Values after check:",o);let r=a?"".concat(a,"/key/generate"):"/key/generate",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},C=async(e,t,o)=>{try{if(console.log("Form Values in keyCreateCall:",o),o.description&&(o.metadata||(o.metadata={}),o.metadata.description=o.description,delete o.description,o.metadata=JSON.stringify(o.metadata)),o.auto_create_key=!1,o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}console.log("Form Values after check:",o);let r=a?"".concat(a,"/user/new"):"/user/new",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},S=async(e,t)=>{try{let o=a?"".concat(a,"/key/delete"):"/key/delete";console.log("in keyDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({keys:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to create key:",e),e}},N=async(e,t)=>{try{let o=a?"".concat(a,"/user/delete"):"/user/delete";console.log("in userDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_ids:t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete user(s):",e),e}},v=async(e,t)=>{try{let o=a?"".concat(a,"/team/delete"):"/team/delete";console.log("in teamDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_ids:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete key:",e),e}},F=async function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null,c=arguments.length>5&&void 0!==arguments[5]?arguments[5]:null,s=arguments.length>6&&void 0!==arguments[6]?arguments[6]:null,i=arguments.length>7&&void 0!==arguments[7]?arguments[7]:null,h=arguments.length>8&&void 0!==arguments[8]?arguments[8]:null,p=arguments.length>9&&void 0!==arguments[9]?arguments[9]:null;try{let w=a?"".concat(a,"/user/list"):"/user/list";console.log("in userListCall");let u=new URLSearchParams;if(t&&t.length>0){let e=t.join(",");u.append("user_ids",e)}o&&u.append("page",o.toString()),r&&u.append("page_size",r.toString()),n&&u.append("user_email",n),c&&u.append("role",c),s&&u.append("team",s),i&&u.append("sso_user_ids",i),h&&u.append("sort_by",h),p&&u.append("sort_order",p);let g=u.toString();g&&(w+="?".concat(g));let f=await fetch(w,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!f.ok){let e=await f.text();throw l(e),Error("Network response was not ok")}let y=await f.json();return console.log("/user/list API Response:",y),y}catch(e){throw console.error("Failed to create key:",e),e}},b=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],n=arguments.length>4?arguments[4]:void 0,c=arguments.length>5?arguments[5]:void 0,s=arguments.length>6&&void 0!==arguments[6]&&arguments[6];console.log("userInfoCall: ".concat(t,", ").concat(o,", ").concat(r,", ").concat(n,", ").concat(c,", ").concat(s));try{let i;if(r){i=a?"".concat(a,"/user/list"):"/user/list";let e=new URLSearchParams;null!=n&&e.append("page",n.toString()),null!=c&&e.append("page_size",c.toString()),i+="?".concat(e.toString())}else i=a?"".concat(a,"/user/info"):"/user/info",("Admin"!==o&&"Admin Viewer"!==o||s)&&t&&(i+="?user_id=".concat(t));console.log("Requesting user data from:",i);let h=await fetch(i,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("API Response:",p),p}catch(e){throw console.error("Failed to fetch user data:",e),e}},x=async(e,t)=>{try{let o=a?"".concat(a,"/team/info"):"/team/info";t&&(o="".concat(o,"?team_id=").concat(t)),console.log("in teamInfoCall");let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},O=async function(e,t){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;arguments.length>5&&void 0!==arguments[5]&&arguments[5],arguments.length>6&&void 0!==arguments[6]&&arguments[6],arguments.length>7&&void 0!==arguments[7]&&arguments[7],arguments.length>8&&void 0!==arguments[8]&&arguments[8];try{let c=a?"".concat(a,"/v2/team/list"):"/v2/team/list";console.log("in teamInfoCall");let s=new URLSearchParams;o&&s.append("user_id",o.toString()),t&&s.append("organization_id",t.toString()),r&&s.append("team_id",r.toString()),n&&s.append("team_alias",n.toString());let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("/v2/team/list API Response:",p),p}catch(e){throw console.error("Failed to create key:",e),e}},B=async function(e,t){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/team/list"):"/team/list";console.log("in teamInfoCall");let s=new URLSearchParams;o&&s.append("user_id",o.toString()),t&&s.append("organization_id",t.toString()),r&&s.append("team_id",r.toString()),n&&s.append("team_alias",n.toString());let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("/team/list API Response:",p),p}catch(e){throw console.error("Failed to create key:",e),e}},P=async e=>{try{let t=a?"".concat(a,"/team/available"):"/team/available";console.log("in availableTeamListCall");let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("/team/available_teams API Response:",r),r}catch(e){throw e}},G=async e=>{try{let t=a?"".concat(a,"/organization/list"):"/organization/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},J=async(e,t)=>{try{let o=a?"".concat(a,"/organization/info"):"/organization/info";t&&(o="".concat(o,"?organization_id=").concat(t)),console.log("in teamInfoCall");let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},I=async(e,t)=>{try{if(console.log("Form Values in organizationCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw console.error("Failed to parse metadata:",e),Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/organization/new"):"/organization/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},A=async(e,t)=>{try{console.log("Form Values in organizationUpdateCall:",t);let o=a?"".concat(a,"/organization/update"):"/organization/update",r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("Update Team Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},R=async(e,t)=>{try{let o=a?"".concat(a,"/organization/delete"):"/organization/delete",r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_ids:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Error deleting organization: ".concat(e))}return await r.json()}catch(e){throw console.error("Failed to delete organization:",e),e}},U=async(e,t)=>{try{let o=a?"".concat(a,"/utils/transform_request"):"/utils/transform_request",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to create key:",e),e}},z=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1;try{let n=a?"".concat(a,"/user/daily/activity"):"/user/daily/activity",c=new URLSearchParams;c.append("start_date",t.toISOString()),c.append("end_date",o.toISOString()),c.append("page_size","1000"),c.append("page",r.toString());let s=c.toString();s&&(n+="?".concat(s));let i=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!i.ok){let e=await i.text();throw l(e),Error("Network response was not ok")}return await i.json()}catch(e){throw console.error("Failed to create key:",e),e}},V=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/tag/daily/activity"):"/tag/daily/activity",s=new URLSearchParams;s.append("start_date",t.toISOString()),s.append("end_date",o.toISOString()),s.append("page_size","1000"),s.append("page",r.toString()),n&&s.append("tags",n.join(","));let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}return await h.json()}catch(e){throw console.error("Failed to create key:",e),e}},L=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/team/daily/activity"):"/team/daily/activity",s=new URLSearchParams;s.append("start_date",t.toISOString()),s.append("end_date",o.toISOString()),s.append("page_size","1000"),s.append("page",r.toString()),n&&s.append("team_ids",n.join(",")),s.append("exclude_team_ids","litellm-dashboard");let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}return await h.json()}catch(e){throw console.error("Failed to create key:",e),e}},M=async e=>{try{let t=a?"".concat(a,"/onboarding/get_token"):"/onboarding/get_token";t+="?invite_link=".concat(e);let o=await fetch(t,{method:"GET",headers:{"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},D=async(e,t,o,r)=>{let n=a?"".concat(a,"/onboarding/claim_token"):"/onboarding/claim_token";try{let a=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({invitation_link:t,user_id:o,password:r})});if(!a.ok){let e=await a.text();throw l(e),Error("Network response was not ok")}let c=await a.json();return console.log(c),c}catch(e){throw console.error("Failed to delete key:",e),e}},Z=async(e,t,o)=>{try{let r=a?"".concat(a,"/key/").concat(t,"/regenerate"):"/key/".concat(t,"/regenerate"),n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Regenerate key Response:",c),c}catch(e){throw console.error("Failed to regenerate key:",e),e}},H=!1,q=null,X=async(e,t,o)=>{try{console.log("modelInfoCall:",e,t,o);let n=a?"".concat(a,"/v2/model/info"):"/v2/model/info",c=new URLSearchParams;c.append("include_team_models","true"),c.toString()&&(n+="?".concat(c.toString()));let s=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw e+="error shown=".concat(H),H||(e.includes("No model list passed")&&(e="No Models Exist. Click Add Model to get started."),r.ZP.info(e,10),H=!0,q&&clearTimeout(q),q=setTimeout(()=>{H=!1},1e4)),Error("Network response was not ok")}let i=await s.json();return console.log("modelInfoCall:",i),i}catch(e){throw console.error("Failed to create key:",e),e}},Y=async(e,t)=>{try{let o=a?"".concat(a,"/v1/model/info"):"/v1/model/info";o+="?litellm_model_id=".concat(t);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok)throw await r.text(),Error("Network response was not ok");let n=await r.json();return console.log("modelInfoV1Call:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},$=async e=>{try{let t=a?"".concat(a,"/model_group/info"):"/model_group/info",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log("modelHubCall:",r),r}catch(e){throw console.error("Failed to create key:",e),e}},K=async e=>{try{let t=a?"".concat(a,"/get/allowed_ips"):"/get/allowed_ips",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw Error("Network response was not ok: ".concat(e))}let r=await o.json();return console.log("getAllowedIPs:",r),r.data}catch(e){throw console.error("Failed to get allowed IPs:",e),e}},W=async(e,t)=>{try{let o=a?"".concat(a,"/add/allowed_ip"):"/add/allowed_ip",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({ip:t})});if(!r.ok){let e=await r.text();throw Error("Network response was not ok: ".concat(e))}let n=await r.json();return console.log("addAllowedIP:",n),n}catch(e){throw console.error("Failed to add allowed IP:",e),e}},Q=async(e,t)=>{try{let o=a?"".concat(a,"/delete/allowed_ip"):"/delete/allowed_ip",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({ip:t})});if(!r.ok){let e=await r.text();throw Error("Network response was not ok: ".concat(e))}let n=await r.json();return console.log("deleteAllowedIP:",n),n}catch(e){throw console.error("Failed to delete allowed IP:",e),e}},ee=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics"):"/model/metrics";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},et=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/model/streaming_metrics"):"/model/streaming_metrics";t&&(n="".concat(n,"?_selected_model_group=").concat(t,"&startTime=").concat(o,"&endTime=").concat(r));let c=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}return await c.json()}catch(e){throw console.error("Failed to create key:",e),e}},eo=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics/slow_responses"):"/model/metrics/slow_responses";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},er=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics/exceptions"):"/model/metrics/exceptions";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},ea=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null,c=(arguments.length>5&&void 0!==arguments[5]&&arguments[5],arguments.length>6&&void 0!==arguments[6]&&arguments[6]);console.log("in /models calls, globalLitellmHeaderName",d);try{let t=a?"".concat(a,"/models"):"/models",o=new URLSearchParams;o.append("include_model_access_groups","True"),!0===r&&o.append("return_wildcard_routes","True"),!0===c&&o.append("only_model_access_groups","True"),n&&o.append("team_id",n.toString()),o.toString()&&(t+="?".concat(o.toString()));let s=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}return await s.json()}catch(e){throw console.error("Failed to create key:",e),e}},en=async e=>{try{let t=a?"".concat(a,"/global/spend/teams"):"/global/spend/teams";console.log("in teamSpendLogsCall:",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ec=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/tags"):"/global/spend/tags";t&&o&&(n="".concat(n,"?start_date=").concat(t,"&end_date=").concat(o)),r&&(n+="".concat(n,"&tags=").concat(r.join(","))),console.log("in tagsSpendLogsCall:",n);let c=await fetch("".concat(n),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to create key:",e),e}},es=async e=>{try{let t=a?"".concat(a,"/global/spend/all_tag_names"):"/global/spend/all_tag_names";console.log("in global/spend/all_tag_names call",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ei=async e=>{try{let t=a?"".concat(a,"/global/all_end_users"):"/global/all_end_users";console.log("in global/all_end_users call",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},el=async(e,t)=>{try{let o=a?"".concat(a,"/user/filter/ui"):"/user/filter/ui";t.get("user_email")&&(o+="?user_email=".concat(t.get("user_email"))),t.get("user_id")&&(o+="?user_id=".concat(t.get("user_id")));let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to create key:",e),e}},ed=async(e,t,o,r,n,c,s,i,h,p,w)=>{try{let u=a?"".concat(a,"/spend/logs/ui"):"/spend/logs/ui",g=new URLSearchParams;t&&g.append("api_key",t),o&&g.append("team_id",o),r&&g.append("request_id",r),n&&g.append("start_date",n),c&&g.append("end_date",c),s&&g.append("page",s.toString()),i&&g.append("page_size",i.toString()),h&&g.append("user_id",h),p&&g.append("status_filter",p),w&&g.append("model",w);let f=g.toString();f&&(u+="?".concat(f));let y=await fetch(u,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!y.ok){let e=await y.text();throw l(e),Error("Network response was not ok")}let m=await y.json();return console.log("Spend Logs Response:",m),m}catch(e){throw console.error("Failed to fetch spend logs:",e),e}},eh=async e=>{try{let t=a?"".concat(a,"/global/spend/logs"):"/global/spend/logs",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ep=async e=>{try{let t=a?"".concat(a,"/global/spend/keys?limit=5"):"/global/spend/keys?limit=5",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ew=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/end_users"):"/global/spend/end_users",c="";c=t?JSON.stringify({api_key:t,startTime:o,endTime:r}):JSON.stringify({startTime:o,endTime:r});let s={method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:c},i=await fetch(n,s);if(!i.ok){let e=await i.text();throw l(e),Error("Network response was not ok")}let h=await i.json();return console.log(h),h}catch(e){throw console.error("Failed to create key:",e),e}},eu=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/provider"):"/global/spend/provider";o&&r&&(n+="?start_date=".concat(o,"&end_date=").concat(r)),t&&(n+="&api_key=".concat(t));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},eg=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity"):"/global/activity";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ef=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity/cache_hits"):"/global/activity/cache_hits";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ey=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity/model"):"/global/activity/model";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},em=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/activity/exceptions"):"/global/activity/exceptions";t&&o&&(n+="?start_date=".concat(t,"&end_date=").concat(o)),r&&(n+="&model_group=".concat(r));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok)throw await s.text(),Error("Network response was not ok");let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ek=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/activity/exceptions/deployment"):"/global/activity/exceptions/deployment";t&&o&&(n+="?start_date=".concat(t,"&end_date=").concat(o)),r&&(n+="&model_group=".concat(r));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok)throw await s.text(),Error("Network response was not ok");let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},e_=async e=>{try{let t=a?"".concat(a,"/global/spend/models?limit=5"):"/global/spend/models?limit=5",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},eT=async(e,t)=>{try{let o=a?"".concat(a,"/v2/key/info"):"/v2/key/info",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({keys:t})});if(!r.ok){let e=await r.text();if(e.includes("Invalid proxy server token passed"))throw Error("Invalid proxy server token passed");throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to create key:",e),e}},ej=async(e,t,o)=>{try{console.log("Sending model connection test request:",JSON.stringify(t));let n=a?"".concat(a,"/health/test_connection"):"/health/test_connection",c=await fetch(n,{method:"POST",headers:{"Content-Type":"application/json",[d]:"Bearer ".concat(e)},body:JSON.stringify({litellm_params:t,mode:o})}),s=c.headers.get("content-type");if(!s||!s.includes("application/json")){let e=await c.text();throw console.error("Received non-JSON response:",e),Error("Received non-JSON response (".concat(c.status,": ").concat(c.statusText,"). Check network tab for details."))}let i=await c.json();if(!c.ok||"error"===i.status){if("error"===i.status);else{var r;return{status:"error",message:(null===(r=i.error)||void 0===r?void 0:r.message)||"Connection test failed: ".concat(c.status," ").concat(c.statusText)}}}return i}catch(e){throw console.error("Model connection test error:",e),e}},eE=async(e,t)=>{try{console.log("entering keyInfoV1Call");let o=a?"".concat(a,"/key/info"):"/key/info";o="".concat(o,"?key=").concat(t);let n=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(console.log("response",n),!n.ok){let e=await n.text();l(e),r.ZP.error("Failed to fetch key info - "+e)}let c=await n.json();return console.log("data",c),c}catch(e){throw console.error("Failed to fetch key info:",e),e}},eC=async function(e,t,o,r,n,c,s,i){let h=arguments.length>8&&void 0!==arguments[8]?arguments[8]:null,p=arguments.length>9&&void 0!==arguments[9]?arguments[9]:null;try{let w=a?"".concat(a,"/key/list"):"/key/list";console.log("in keyListCall");let u=new URLSearchParams;o&&u.append("team_id",o.toString()),t&&u.append("organization_id",t.toString()),r&&u.append("key_alias",r),c&&u.append("key_hash",c),n&&u.append("user_id",n.toString()),s&&u.append("page",s.toString()),i&&u.append("size",i.toString()),h&&u.append("sort_by",h),p&&u.append("sort_order",p),u.append("return_full_object","true"),u.append("include_team_keys","true");let g=u.toString();g&&(w+="?".concat(g));let f=await fetch(w,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!f.ok){let e=await f.text();throw l(e),Error("Network response was not ok")}let y=await f.json();return console.log("/team/list API Response:",y),y}catch(e){throw console.error("Failed to create key:",e),e}},eS=async(e,t)=>{try{let o=a?"".concat(a,"/user/get_users?role=").concat(t):"/user/get_users?role=".concat(t);console.log("in userGetAllUsersCall:",o);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to get requested models:",e),e}},eN=async e=>{try{let t=a?"".concat(a,"/user/available_roles"):"/user/available_roles",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log("response from user/available_role",r),r}catch(e){throw e}},ev=async(e,t)=>{try{if(console.log("Form Values in teamCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/team/new"):"/team/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eF=async(e,t)=>{try{if(console.log("Form Values in credentialCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/credentials"):"/credentials",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eb=async e=>{try{let t=a?"".concat(a,"/credentials"):"/credentials";console.log("in credentialListCall");let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("/credentials API Response:",r),r}catch(e){throw console.error("Failed to create key:",e),e}},ex=async(e,t,o)=>{try{let r=a?"".concat(a,"/credentials"):"/credentials";t?r+="/by_name/".concat(t):o&&(r+="/by_model/".concat(o)),console.log("in credentialListCall");let n=await fetch(r,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("/credentials API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eO=async(e,t)=>{try{let o=a?"".concat(a,"/credentials/").concat(t):"/credentials/".concat(t);console.log("in credentialDeleteCall:",t);let r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete key:",e),e}},eB=async(e,t,o)=>{try{if(console.log("Form Values in credentialUpdateCall:",o),o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let r=a?"".concat(a,"/credentials/").concat(t):"/credentials/".concat(t),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eP=async(e,t)=>{try{if(console.log("Form Values in keyUpdateCall:",t),t.model_tpm_limit){console.log("formValues.model_tpm_limit:",t.model_tpm_limit);try{t.model_tpm_limit=JSON.parse(t.model_tpm_limit)}catch(e){throw Error("Failed to parse model_tpm_limit: "+e)}}if(t.model_rpm_limit){console.log("formValues.model_rpm_limit:",t.model_rpm_limit);try{t.model_rpm_limit=JSON.parse(t.model_rpm_limit)}catch(e){throw Error("Failed to parse model_rpm_limit: "+e)}}let o=a?"".concat(a,"/key/update"):"/key/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("Update key Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eG=async(e,t)=>{try{console.log("Form Values in teamUpateCall:",t);let o=a?"".concat(a,"/team/update"):"/team/update",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),r.ZP.error("Failed to update team settings: "+e),Error(e)}let c=await n.json();return console.log("Update Team Response:",c),c}catch(e){throw console.error("Failed to update team:",e),e}},eJ=async(e,t,o)=>{try{console.log("Form Values in modelUpateCall:",t);let r=a?"".concat(a,"/model/").concat(o,"/update"):"/model/".concat(o,"/update"),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error update from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("Update model Response:",c),c}catch(e){throw console.error("Failed to update model:",e),e}},eI=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let n=a?"".concat(a,"/team/member_add"):"/team/member_add",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,member:o})});if(!c.ok){var r;let e=await c.text(),t={};try{t=JSON.parse(e)}catch(t){console.warn("Failed to parse error body as JSON:",e)}let o=(null==t?void 0:null===(r=t.detail)||void 0===r?void 0:r.error)||"Failed to add team member",a=Error(o);throw a.raw=t,a}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to create key:",e),e}},eA=async(e,t,o)=>{try{console.log("Form Values in teamMemberUpdateCall:",o);let n=a?"".concat(a,"/team/member_update"):"/team/member_update",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,role:o.role,user_id:o.user_id})});if(!c.ok){var r;let e=await c.text(),t={};try{t=JSON.parse(e)}catch(t){console.warn("Failed to parse error body as JSON:",e)}let o=(null==t?void 0:null===(r=t.detail)||void 0===r?void 0:r.error)||"Failed to add team member",a=Error(o);throw a.raw=t,a}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to update team member:",e),e}},eR=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let r=a?"".concat(a,"/team/member_delete"):"/team/member_delete",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,...void 0!==o.user_email&&{user_email:o.user_email},...void 0!==o.user_id&&{user_id:o.user_id}})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eU=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let r=a?"".concat(a,"/organization/member_add"):"/organization/member_add",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,member:o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create organization member:",e),e}},ez=async(e,t,o)=>{try{console.log("Form Values in organizationMemberDeleteCall:",o);let r=a?"".concat(a,"/organization/member_delete"):"/organization/member_delete",n=await fetch(r,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,user_id:o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to delete organization member:",e),e}},eV=async(e,t,o)=>{try{console.log("Form Values in organizationMemberUpdateCall:",o);let r=a?"".concat(a,"/organization/member_update"):"/organization/member_update",n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to update organization member:",e),e}},eL=async(e,t,o)=>{try{console.log("Form Values in userUpdateUserCall:",t);let r=a?"".concat(a,"/user/update"):"/user/update",n={...t};null!==o&&(n.user_role=o),n=JSON.stringify(n);let c=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:n});if(!c.ok){let e=await c.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to create key:",e),e}},eM=async(e,t)=>{try{let o=a?"".concat(a,"/health/services?service=").concat(t):"/health/services?service=".concat(t);console.log("Checking Slack Budget Alerts service health");let n=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error(e)}let c=await n.json();return r.ZP.success("Test request to ".concat(t," made - check logs/alerts on ").concat(t," to verify")),c}catch(e){throw console.error("Failed to perform health check:",e),e}},eD=async e=>{try{let t=a?"".concat(a,"/budget/list"):"/budget/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eZ=async(e,t,o)=>{try{let t=a?"".concat(a,"/get/config/callbacks"):"/get/config/callbacks",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eH=async e=>{try{let t=a?"".concat(a,"/config/list?config_type=general_settings"):"/config/list?config_type=general_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eq=async e=>{try{let t=a?"".concat(a,"/config/pass_through_endpoint"):"/config/pass_through_endpoint",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eX=async(e,t)=>{try{let o=a?"".concat(a,"/config/field/info?field_name=").concat(t):"/config/field/info?field_name=".concat(t),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok)throw await r.text(),Error("Network response was not ok");return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},eY=async(e,t)=>{try{let o=a?"".concat(a,"/config/pass_through_endpoint"):"/config/pass_through_endpoint",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},e$=async(e,t,o)=>{try{let n=a?"".concat(a,"/config/field/update"):"/config/field/update",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({field_name:t,field_value:o,config_type:"general_settings"})});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}let s=await c.json();return r.ZP.success("Successfully updated value!"),s}catch(e){throw console.error("Failed to set callbacks:",e),e}},eK=async(e,t)=>{try{let o=a?"".concat(a,"/config/field/delete"):"/config/field/delete",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({field_name:t,config_type:"general_settings"})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return r.ZP.success("Field reset on proxy"),c}catch(e){throw console.error("Failed to get callbacks:",e),e}},eW=async(e,t)=>{try{let o=a?"".concat(a,"/config/pass_through_endpoint?endpoint_id=").concat(t):"/config/pass_through_endpoint".concat(t),r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eQ=async(e,t)=>{try{let o=a?"".concat(a,"/config/update"):"/config/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},e0=async e=>{try{let t=a?"".concat(a,"/health"):"/health",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to call /health:",e),e}},e1=async(e,t)=>{try{let o=a?"".concat(a,"/health?model=").concat(encodeURIComponent(t)):"/health?model=".concat(encodeURIComponent(t)),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw Error(e||"Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to call /health for model ".concat(t,":"),e),e}},e2=async e=>{try{let t=a?"".concat(a,"/cache/ping"):"/cache/ping",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error(e)}return await o.json()}catch(e){throw console.error("Failed to call /cache/ping:",e),e}},e3=async e=>{try{let t=a?"".concat(a,"/health/latest"):"/health/latest",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error(e)}return await o.json()}catch(e){throw console.error("Failed to call /health/latest:",e),e}},e4=async e=>{try{console.log("Getting proxy UI settings"),console.log("proxyBaseUrl in getProxyUISettings:",a);let t=a?"".concat(a,"/sso/get/ui_settings"):"/sso/get/ui_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},e5=async e=>{try{let t=a?"".concat(a,"/v2/guardrails/list"):"/v2/guardrails/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get guardrails list:",e),e}},e6=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails"):"/guardrails",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({guardrail:t})});if(!r.ok){let e=await r.text();throw l(e),Error(e)}let n=await r.json();return console.log("Create guardrail response:",n),n}catch(e){throw console.error("Failed to create guardrail:",e),e}},e8=async(e,t,o)=>{try{let r=a?"".concat(a,"/spend/logs/ui/").concat(t,"?start_date=").concat(encodeURIComponent(o)):"/spend/logs/ui/".concat(t,"?start_date=").concat(encodeURIComponent(o));console.log("Fetching log details from:",r);let n=await fetch(r,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Fetched log details:",c),c}catch(e){throw console.error("Failed to fetch log details:",e),e}},e9=async e=>{try{let t=a?"".concat(a,"/get/internal_user_settings"):"/get/internal_user_settings";console.log("Fetching SSO settings from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched SSO settings:",r),r}catch(e){throw console.error("Failed to fetch SSO settings:",e),e}},e7=async(e,t)=>{try{let o=a?"".concat(a,"/update/internal_user_settings"):"/update/internal_user_settings";console.log("Updating internal user settings:",t);let n=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Updated internal user settings:",c),r.ZP.success("Internal user settings updated successfully"),c}catch(e){throw console.error("Failed to update internal user settings:",e),e}},te=async e=>{try{let t=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server";console.log("Fetching MCP servers from:",t);let o=await fetch(t,{method:s.GET,headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched MCP servers:",r),r}catch(e){throw console.error("Failed to fetch MCP servers:",e),e}},tt=async(e,t)=>{try{console.log("Form Values in createMCPServer:",t);let o=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},to=async(e,t)=>{try{let o=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server",r=await fetch(o,{method:"PUT",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to update MCP server:",e),e}},tr=async(e,t)=>{try{let o=(a?"".concat(a):"")+"/v1/mcp/server/".concat(t);console.log("in deleteMCPServer:",t);let r=await fetch(o,{method:s.DELETE,headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}}catch(e){throw console.error("Failed to delete key:",e),e}},ta=async(e,t)=>{try{let o=a?"".concat(a,"/mcp-rest/tools/list?server_id=").concat(t):"/mcp-rest/tools/list?server_id=".concat(t);console.log("Fetching MCP tools from:",o);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Fetched MCP tools:",n),n}catch(e){throw console.error("Failed to fetch MCP tools:",e),e}},tn=async(e,t,o)=>{try{let r=a?"".concat(a,"/mcp-rest/tools/call"):"/mcp-rest/tools/call";console.log("Calling MCP tool:",t,"with arguments:",o);let n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({name:t,arguments:o})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("MCP tool call response:",c),c}catch(e){throw console.error("Failed to call MCP tool:",e),e}},tc=async(e,t)=>{try{let o=a?"".concat(a,"/tag/new"):"/tag/new",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error creating tag:",e),e}},ts=async(e,t)=>{try{let o=a?"".concat(a,"/tag/update"):"/tag/update",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error updating tag:",e),e}},ti=async(e,t)=>{try{let o=a?"".concat(a,"/tag/info"):"/tag/info",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({names:t})});if(!r.ok){let e=await r.text();return await l(e),{}}return await r.json()}catch(e){throw console.error("Error getting tag info:",e),e}},tl=async e=>{try{let t=a?"".concat(a,"/tag/list"):"/tag/list",o=await fetch(t,{method:"GET",headers:{Authorization:"Bearer ".concat(e)}});if(!o.ok){let e=await o.text();return await l(e),{}}return await o.json()}catch(e){throw console.error("Error listing tags:",e),e}},td=async(e,t)=>{try{let o=a?"".concat(a,"/tag/delete"):"/tag/delete",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({name:t})});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error deleting tag:",e),e}},th=async e=>{try{let t=a?"".concat(a,"/get/default_team_settings"):"/get/default_team_settings";console.log("Fetching default team settings from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched default team settings:",r),r}catch(e){throw console.error("Failed to fetch default team settings:",e),e}},tp=async(e,t)=>{try{let o=a?"".concat(a,"/update/default_team_settings"):"/update/default_team_settings";console.log("Updating default team settings:",t);let n=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Updated default team settings:",c),r.ZP.success("Default team settings updated successfully"),c}catch(e){throw console.error("Failed to update default team settings:",e),e}},tw=async(e,t)=>{try{let o=a?"".concat(a,"/team/permissions_list?team_id=").concat(t):"/team/permissions_list?team_id=".concat(t),r=await fetch(o,{method:"GET",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Team permissions response:",n),n}catch(e){throw console.error("Failed to get team permissions:",e),e}},tu=async(e,t,o)=>{try{let r=a?"".concat(a,"/team/permissions_update"):"/team/permissions_update",n=await fetch(r,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({team_id:t,team_member_permissions:o})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Team permissions response:",c),c}catch(e){throw console.error("Failed to update team permissions:",e),e}},tg=async(e,t)=>{try{let o=a?"".concat(a,"/spend/logs/session/ui?session_id=").concat(encodeURIComponent(t)):"/spend/logs/session/ui?session_id=".concat(encodeURIComponent(t)),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to fetch session logs:",e),e}},tf=async(e,t)=>{try{let o=a?"".concat(a,"/vector_store/new"):"/vector_store/new",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.json();throw Error(e.detail||"Failed to create vector store")}return await r.json()}catch(e){throw console.error("Error creating vector store:",e),e}},ty=async function(e){arguments.length>1&&void 0!==arguments[1]&&arguments[1],arguments.length>2&&void 0!==arguments[2]&&arguments[2];try{let t=a?"".concat(a,"/vector_store/list"):"/vector_store/list",o=await fetch(t,{method:"GET",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)}});if(!o.ok){let e=await o.json();throw Error(e.detail||"Failed to list vector stores")}return await o.json()}catch(e){throw console.error("Error listing vector stores:",e),e}},tm=async(e,t)=>{try{let o=a?"".concat(a,"/vector_store/delete"):"/vector_store/delete",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({vector_store_id:t})});if(!r.ok){let e=await r.json();throw Error(e.detail||"Failed to delete vector store")}return await r.json()}catch(e){throw console.error("Error deleting vector store:",e),e}},tk=async e=>{try{let t=a?"".concat(a,"/email/event_settings"):"/email/event_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get email event settings")}let r=await o.json();return console.log("Email event settings response:",r),r}catch(e){throw console.error("Failed to get email event settings:",e),e}},t_=async(e,t)=>{try{let o=a?"".concat(a,"/email/event_settings"):"/email/event_settings",r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Failed to update email event settings")}let n=await r.json();return console.log("Update email event settings response:",n),n}catch(e){throw console.error("Failed to update email event settings:",e),e}},tT=async e=>{try{let t=a?"".concat(a,"/email/event_settings/reset"):"/email/event_settings/reset",o=await fetch(t,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to reset email event settings")}let r=await o.json();return console.log("Reset email event settings response:",r),r}catch(e){throw console.error("Failed to reset email event settings:",e),e}},tj=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails/").concat(t):"/guardrails/".concat(t),r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error(e)}let n=await r.json();return console.log("Delete guardrail response:",n),n}catch(e){throw console.error("Failed to delete guardrail:",e),e}},tE=async e=>{try{let t=a?"".concat(a,"/guardrails/ui/add_guardrail_settings"):"/guardrails/ui/add_guardrail_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get guardrail UI settings")}let r=await o.json();return console.log("Guardrail UI settings response:",r),r}catch(e){throw console.error("Failed to get guardrail UI settings:",e),e}},tC=async e=>{try{let t=a?"".concat(a,"/guardrails/ui/provider_specific_params"):"/guardrails/ui/provider_specific_params",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get guardrail provider specific parameters")}let r=await o.json();return console.log("Guardrail provider specific params response:",r),r}catch(e){throw console.error("Failed to get guardrail provider specific parameters:",e),e}},tS=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails/").concat(t,"/info"):"/guardrails/".concat(t,"/info"),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Failed to get guardrail info")}let n=await r.json();return console.log("Guardrail info response:",n),n}catch(e){throw console.error("Failed to get guardrail info:",e),e}},tN=async(e,t,o)=>{try{let r=a?"".concat(a,"/guardrails/").concat(t):"/guardrails/".concat(t),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!n.ok){let e=await n.text();throw l(e),Error("Failed to update guardrail")}let c=await n.json();return console.log("Update guardrail response:",c),c}catch(e){throw console.error("Failed to update guardrail:",e),e}},tv=async e=>{try{let t=a?"".concat(a,"/get/sso_settings"):"/get/sso_settings";console.log("Fetching SSO configuration from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched SSO configuration:",r),r}catch(e){throw console.error("Failed to fetch SSO configuration:",e),e}},tF=async(e,t)=>{try{let o=a?"".concat(a,"/update/sso_settings"):"/update/sso_settings";console.log("Updating SSO configuration:",t);let r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Updated SSO configuration:",n),n}catch(e){throw console.error("Failed to update SSO configuration:",e),e}},tb=async(e,t,o,r,n)=>{try{let t=a?"".concat(a,"/audit"):"/audit",o=new URLSearchParams;r&&o.append("page",r.toString()),n&&o.append("page_size",n.toString());let c=o.toString();c&&(t+="?".concat(c));let s=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}return await s.json()}catch(e){throw console.error("Failed to fetch audit logs:",e),e}},tx=async e=>{try{let t=a?"".concat(a,"/user/available_users"):"/user/available_users",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e)}});if(!o.ok){if(404===o.status)return null;let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to fetch remaining users:",e),e}},tO=async(e,t,o)=>{try{let n=a?"".concat(a,"/config/pass_through_endpoint/").concat(encodeURIComponent(t)):"/config/pass_through_endpoint/".concat(encodeURIComponent(t)),c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}let s=await c.json();return r.ZP.success("Pass through endpoint updated successfully"),s}catch(e){throw console.error("Failed to update pass through endpoint:",e),e}},tB=async(e,t)=>{try{let o=a?"".concat(a,"/config/callback/delete"):"/config/callback/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({callback_name:t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to delete specific callback:",e),e}}}}]); \ No newline at end of file +"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[250],{19250:function(e,t,o){o.d(t,{$D:function(){return eJ},$I:function(){return Q},$o:function(){return tF},AZ:function(){return X},Au:function(){return e_},BL:function(){return eZ},Br:function(){return b},Bw:function(){return tx},E9:function(){return eX},EB:function(){return th},EG:function(){return eW},EY:function(){return e0},Eb:function(){return N},FC:function(){return eh},Gh:function(){return eG},H1:function(){return I},H2:function(){return a},Hx:function(){return ej},I1:function(){return S},It:function(){return B},J$:function(){return ec},JO:function(){return O},K8:function(){return h},K_:function(){return eQ},Ko:function(){return tN},LY:function(){return eV},Lp:function(){return eR},MO:function(){return p},Mx:function(){return tf},N3:function(){return eb},N8:function(){return er},NL:function(){return e9},NV:function(){return m},Nc:function(){return eP},Nz:function(){return e1},O3:function(){return eD},OD:function(){return eC},OU:function(){return eu},Of:function(){return F},Og:function(){return y},Ou:function(){return ty},Ov:function(){return C},Oz:function(){return tB},PC:function(){return e6},PT:function(){return K},PY:function(){return tv},Pj:function(){return tt},Pv:function(){return tm},Qg:function(){return ex},RQ:function(){return j},Rg:function(){return et},Sb:function(){return ez},So:function(){return ea},TF:function(){return tu},Tj:function(){return e2},Tx:function(){return tO},U8:function(){return te},UM:function(){return tl},VA:function(){return A},Vt:function(){return eY},W_:function(){return M},X:function(){return es},XB:function(){return tg},XO:function(){return T},Xd:function(){return eS},Xm:function(){return x},YU:function(){return eH},Yi:function(){return tj},Yo:function(){return U},Z9:function(){return V},Zr:function(){return k},a6:function(){return P},aC:function(){return tw},ao:function(){return eK},b1:function(){return ew},cq:function(){return R},cu:function(){return eI},e2:function(){return eT},eH:function(){return W},eW:function(){return tC},eZ:function(){return eB},fE:function(){return td},fP:function(){return eo},fk:function(){return tS},g:function(){return e4},gX:function(){return eO},gl:function(){return to},h3:function(){return ed},hT:function(){return ev},hy:function(){return f},ix:function(){return Y},j2:function(){return ei},jA:function(){return e$},jE:function(){return eM},jr:function(){return tT},kK:function(){return g},kn:function(){return $},lP:function(){return w},lU:function(){return ta},lg:function(){return eN},mC:function(){return ti},mR:function(){return en},mY:function(){return tc},m_:function(){return D},mp:function(){return eq},n$:function(){return em},n9:function(){return ts},nJ:function(){return tb},nd:function(){return e7},o6:function(){return ee},oC:function(){return eF},ol:function(){return L},pf:function(){return eL},pu:function(){return tk},qI:function(){return _},qW:function(){return t_},qd:function(){return tE},qk:function(){return e8},qm:function(){return u},r1:function(){return tp},r6:function(){return G},rs:function(){return v},s0:function(){return Z},sN:function(){return eA},t$:function(){return J},t0:function(){return eE},t3:function(){return e5},tB:function(){return tn},tN:function(){return ep},u5:function(){return el},v9:function(){return ek},vh:function(){return eU},wX:function(){return E},wd:function(){return eg},xA:function(){return ey},xO:function(){return e3},xX:function(){return z},xZ:function(){return tr},zX:function(){return c},zg:function(){return ef}});var r=o(41021);let a=null;console.log=function(){};let n=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,o=window.location.origin,r=t||o;console.log("proxyBaseUrl:",a),console.log("serverRootPath:",e),e.length>0&&!r.endsWith(e)&&"/"!=e&&(r+=e,a=r),console.log("Updated proxyBaseUrl:",a)},c=()=>a||window.location.origin,s={GET:"GET",DELETE:"DELETE"},i=0,l=async e=>{let t=Date.now();t-i>6e4?(e.includes("Authentication Error - Expired Key")&&(r.ZP.info("UI Session Expired. Logging out."),i=t,document.cookie="token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;",window.location.href=window.location.pathname),i=t):console.log("Error suppressed to prevent spam:",e)},d="Authorization";function h(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"Authorization";console.log("setGlobalLitellmHeaderName: ".concat(e)),d=e}let p=async()=>{console.log("Getting UI config");let e=await fetch("/.well-known/litellm-ui-config"),t=await e.json();return console.log("jsonData in getUiConfig:",t),n(t.server_root_path,t.proxy_base_url),t},w=async()=>{let e=a?"".concat(a,"/openapi.json"):"/openapi.json",t=await fetch(e);return await t.json()},u=async e=>{try{let t=a?"".concat(a,"/get/litellm_model_cost_map"):"/get/litellm_model_cost_map",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}}),r=await o.json();return console.log("received litellm model cost data: ".concat(r)),r}catch(e){throw console.error("Failed to get model cost map:",e),e}},g=async(e,t)=>{try{let o=a?"".concat(a,"/model/new"):"/model/new",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text()||"Network response was not ok";throw r.ZP.error(e),Error(e)}let c=await n.json();return console.log("API Response:",c),r.ZP.destroy(),r.ZP.success("Model ".concat(t.model_name," created successfully"),2),c}catch(e){throw console.error("Failed to create key:",e),e}},f=async e=>{try{let t=a?"".concat(a,"/model/settings"):"/model/settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){console.error("Failed to get model settings:",e)}},y=async(e,t)=>{console.log("model_id in model delete call: ".concat(t));try{let o=a?"".concat(a,"/model/delete"):"/model/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},m=async(e,t)=>{if(console.log("budget_id in budget delete call: ".concat(t)),null!=e)try{let o=a?"".concat(a,"/budget/delete"):"/budget/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},k=async(e,t)=>{try{console.log("Form Values in budgetCreateCall:",t),console.log("Form Values after check:",t);let o=a?"".concat(a,"/budget/new"):"/budget/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},_=async(e,t)=>{try{console.log("Form Values in budgetUpdateCall:",t),console.log("Form Values after check:",t);let o=a?"".concat(a,"/budget/update"):"/budget/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},T=async(e,t)=>{try{let o=a?"".concat(a,"/invitation/new"):"/invitation/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},j=async e=>{try{let t=a?"".concat(a,"/alerting/settings"):"/alerting/settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},E=async(e,t,o)=>{try{if(console.log("Form Values in keyCreateCall:",o),o.description&&(o.metadata||(o.metadata={}),o.metadata.description=o.description,delete o.description,o.metadata=JSON.stringify(o.metadata)),o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}console.log("Form Values after check:",o);let r=a?"".concat(a,"/key/generate"):"/key/generate",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},C=async(e,t,o)=>{try{if(console.log("Form Values in keyCreateCall:",o),o.description&&(o.metadata||(o.metadata={}),o.metadata.description=o.description,delete o.description,o.metadata=JSON.stringify(o.metadata)),o.auto_create_key=!1,o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}console.log("Form Values after check:",o);let r=a?"".concat(a,"/user/new"):"/user/new",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},S=async(e,t)=>{try{let o=a?"".concat(a,"/key/delete"):"/key/delete";console.log("in keyDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({keys:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to create key:",e),e}},N=async(e,t)=>{try{let o=a?"".concat(a,"/user/delete"):"/user/delete";console.log("in userDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({user_ids:t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete user(s):",e),e}},v=async(e,t)=>{try{let o=a?"".concat(a,"/team/delete"):"/team/delete";console.log("in teamDeleteCall:",t);let r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_ids:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete key:",e),e}},F=async function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null,c=arguments.length>5&&void 0!==arguments[5]?arguments[5]:null,s=arguments.length>6&&void 0!==arguments[6]?arguments[6]:null,i=arguments.length>7&&void 0!==arguments[7]?arguments[7]:null,h=arguments.length>8&&void 0!==arguments[8]?arguments[8]:null,p=arguments.length>9&&void 0!==arguments[9]?arguments[9]:null;try{let w=a?"".concat(a,"/user/list"):"/user/list";console.log("in userListCall");let u=new URLSearchParams;if(t&&t.length>0){let e=t.join(",");u.append("user_ids",e)}o&&u.append("page",o.toString()),r&&u.append("page_size",r.toString()),n&&u.append("user_email",n),c&&u.append("role",c),s&&u.append("team",s),i&&u.append("sso_user_ids",i),h&&u.append("sort_by",h),p&&u.append("sort_order",p);let g=u.toString();g&&(w+="?".concat(g));let f=await fetch(w,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!f.ok){let e=await f.text();throw l(e),Error("Network response was not ok")}let y=await f.json();return console.log("/user/list API Response:",y),y}catch(e){throw console.error("Failed to create key:",e),e}},b=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],n=arguments.length>4?arguments[4]:void 0,c=arguments.length>5?arguments[5]:void 0,s=arguments.length>6&&void 0!==arguments[6]&&arguments[6];console.log("userInfoCall: ".concat(t,", ").concat(o,", ").concat(r,", ").concat(n,", ").concat(c,", ").concat(s));try{let i;if(r){i=a?"".concat(a,"/user/list"):"/user/list";let e=new URLSearchParams;null!=n&&e.append("page",n.toString()),null!=c&&e.append("page_size",c.toString()),i+="?".concat(e.toString())}else i=a?"".concat(a,"/user/info"):"/user/info",("Admin"!==o&&"Admin Viewer"!==o||s)&&t&&(i+="?user_id=".concat(t));console.log("Requesting user data from:",i);let h=await fetch(i,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("API Response:",p),p}catch(e){throw console.error("Failed to fetch user data:",e),e}},x=async(e,t)=>{try{let o=a?"".concat(a,"/team/info"):"/team/info";t&&(o="".concat(o,"?team_id=").concat(t)),console.log("in teamInfoCall");let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},O=async function(e,t){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;arguments.length>5&&void 0!==arguments[5]&&arguments[5],arguments.length>6&&void 0!==arguments[6]&&arguments[6],arguments.length>7&&void 0!==arguments[7]&&arguments[7],arguments.length>8&&void 0!==arguments[8]&&arguments[8];try{let c=a?"".concat(a,"/v2/team/list"):"/v2/team/list";console.log("in teamInfoCall");let s=new URLSearchParams;o&&s.append("user_id",o.toString()),t&&s.append("organization_id",t.toString()),r&&s.append("team_id",r.toString()),n&&s.append("team_alias",n.toString());let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("/v2/team/list API Response:",p),p}catch(e){throw console.error("Failed to create key:",e),e}},B=async function(e,t){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:null,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/team/list"):"/team/list";console.log("in teamInfoCall");let s=new URLSearchParams;o&&s.append("user_id",o.toString()),t&&s.append("organization_id",t.toString()),r&&s.append("team_id",r.toString()),n&&s.append("team_alias",n.toString());let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}let p=await h.json();return console.log("/team/list API Response:",p),p}catch(e){throw console.error("Failed to create key:",e),e}},P=async e=>{try{let t=a?"".concat(a,"/team/available"):"/team/available";console.log("in availableTeamListCall");let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("/team/available_teams API Response:",r),r}catch(e){throw e}},G=async e=>{try{let t=a?"".concat(a,"/organization/list"):"/organization/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},J=async(e,t)=>{try{let o=a?"".concat(a,"/organization/info"):"/organization/info";t&&(o="".concat(o,"?organization_id=").concat(t)),console.log("in teamInfoCall");let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},I=async(e,t)=>{try{if(console.log("Form Values in organizationCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw console.error("Failed to parse metadata:",e),Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/organization/new"):"/organization/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},A=async(e,t)=>{try{console.log("Form Values in organizationUpdateCall:",t);let o=a?"".concat(a,"/organization/update"):"/organization/update",r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("Update Team Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},R=async(e,t)=>{try{let o=a?"".concat(a,"/organization/delete"):"/organization/delete",r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_ids:[t]})});if(!r.ok){let e=await r.text();throw l(e),Error("Error deleting organization: ".concat(e))}return await r.json()}catch(e){throw console.error("Failed to delete organization:",e),e}},U=async(e,t)=>{try{let o=a?"".concat(a,"/utils/transform_request"):"/utils/transform_request",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to create key:",e),e}},z=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1;try{let n=a?"".concat(a,"/user/daily/activity"):"/user/daily/activity",c=new URLSearchParams;c.append("start_date",t.toISOString()),c.append("end_date",o.toISOString()),c.append("page_size","1000"),c.append("page",r.toString());let s=c.toString();s&&(n+="?".concat(s));let i=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!i.ok){let e=await i.text();throw l(e),Error("Network response was not ok")}return await i.json()}catch(e){throw console.error("Failed to create key:",e),e}},V=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/tag/daily/activity"):"/tag/daily/activity",s=new URLSearchParams;s.append("start_date",t.toISOString()),s.append("end_date",o.toISOString()),s.append("page_size","1000"),s.append("page",r.toString()),n&&s.append("tags",n.join(","));let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}return await h.json()}catch(e){throw console.error("Failed to create key:",e),e}},L=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null;try{let c=a?"".concat(a,"/team/daily/activity"):"/team/daily/activity",s=new URLSearchParams;s.append("start_date",t.toISOString()),s.append("end_date",o.toISOString()),s.append("page_size","1000"),s.append("page",r.toString()),n&&s.append("team_ids",n.join(",")),s.append("exclude_team_ids","litellm-dashboard");let i=s.toString();i&&(c+="?".concat(i));let h=await fetch(c,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!h.ok){let e=await h.text();throw l(e),Error("Network response was not ok")}return await h.json()}catch(e){throw console.error("Failed to create key:",e),e}},M=async e=>{try{let t=a?"".concat(a,"/onboarding/get_token"):"/onboarding/get_token";t+="?invite_link=".concat(e);let o=await fetch(t,{method:"GET",headers:{"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},D=async(e,t,o,r)=>{let n=a?"".concat(a,"/onboarding/claim_token"):"/onboarding/claim_token";try{let a=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({invitation_link:t,user_id:o,password:r})});if(!a.ok){let e=await a.text();throw l(e),Error("Network response was not ok")}let c=await a.json();return console.log(c),c}catch(e){throw console.error("Failed to delete key:",e),e}},Z=async(e,t,o)=>{try{let r=a?"".concat(a,"/key/").concat(t,"/regenerate"):"/key/".concat(t,"/regenerate"),n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Regenerate key Response:",c),c}catch(e){throw console.error("Failed to regenerate key:",e),e}},H=!1,q=null,X=async(e,t,o)=>{try{console.log("modelInfoCall:",e,t,o);let n=a?"".concat(a,"/v2/model/info"):"/v2/model/info",c=new URLSearchParams;c.append("include_team_models","true"),c.toString()&&(n+="?".concat(c.toString()));let s=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw e+="error shown=".concat(H),H||(e.includes("No model list passed")&&(e="No Models Exist. Click Add Model to get started."),r.ZP.info(e,10),H=!0,q&&clearTimeout(q),q=setTimeout(()=>{H=!1},1e4)),Error("Network response was not ok")}let i=await s.json();return console.log("modelInfoCall:",i),i}catch(e){throw console.error("Failed to create key:",e),e}},Y=async(e,t)=>{try{let o=a?"".concat(a,"/v1/model/info"):"/v1/model/info";o+="?litellm_model_id=".concat(t);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok)throw await r.text(),Error("Network response was not ok");let n=await r.json();return console.log("modelInfoV1Call:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},$=async e=>{try{let t=a?"".concat(a,"/model_group/info"):"/model_group/info",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log("modelHubCall:",r),r}catch(e){throw console.error("Failed to create key:",e),e}},K=async e=>{try{let t=a?"".concat(a,"/get/allowed_ips"):"/get/allowed_ips",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw Error("Network response was not ok: ".concat(e))}let r=await o.json();return console.log("getAllowedIPs:",r),r.data}catch(e){throw console.error("Failed to get allowed IPs:",e),e}},W=async(e,t)=>{try{let o=a?"".concat(a,"/add/allowed_ip"):"/add/allowed_ip",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({ip:t})});if(!r.ok){let e=await r.text();throw Error("Network response was not ok: ".concat(e))}let n=await r.json();return console.log("addAllowedIP:",n),n}catch(e){throw console.error("Failed to add allowed IP:",e),e}},Q=async(e,t)=>{try{let o=a?"".concat(a,"/delete/allowed_ip"):"/delete/allowed_ip",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({ip:t})});if(!r.ok){let e=await r.text();throw Error("Network response was not ok: ".concat(e))}let n=await r.json();return console.log("deleteAllowedIP:",n),n}catch(e){throw console.error("Failed to delete allowed IP:",e),e}},ee=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics"):"/model/metrics";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},et=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/model/streaming_metrics"):"/model/streaming_metrics";t&&(n="".concat(n,"?_selected_model_group=").concat(t,"&startTime=").concat(o,"&endTime=").concat(r));let c=await fetch(n,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}return await c.json()}catch(e){throw console.error("Failed to create key:",e),e}},eo=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics/slow_responses"):"/model/metrics/slow_responses";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},er=async(e,t,o,r,n,c,s,i)=>{try{let t=a?"".concat(a,"/model/metrics/exceptions"):"/model/metrics/exceptions";r&&(t="".concat(t,"?_selected_model_group=").concat(r,"&startTime=").concat(n,"&endTime=").concat(c,"&api_key=").concat(s,"&customer=").concat(i));let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to create key:",e),e}},ea=async function(e,t,o){let r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],n=arguments.length>4&&void 0!==arguments[4]?arguments[4]:null,c=(arguments.length>5&&void 0!==arguments[5]&&arguments[5],arguments.length>6&&void 0!==arguments[6]&&arguments[6]);console.log("in /models calls, globalLitellmHeaderName",d);try{let t=a?"".concat(a,"/models"):"/models",o=new URLSearchParams;o.append("include_model_access_groups","True"),!0===r&&o.append("return_wildcard_routes","True"),!0===c&&o.append("only_model_access_groups","True"),n&&o.append("team_id",n.toString()),o.toString()&&(t+="?".concat(o.toString()));let s=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}return await s.json()}catch(e){throw console.error("Failed to create key:",e),e}},en=async e=>{try{let t=a?"".concat(a,"/global/spend/teams"):"/global/spend/teams";console.log("in teamSpendLogsCall:",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ec=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/tags"):"/global/spend/tags";t&&o&&(n="".concat(n,"?start_date=").concat(t,"&end_date=").concat(o)),r&&(n+="".concat(n,"&tags=").concat(r.join(","))),console.log("in tagsSpendLogsCall:",n);let c=await fetch("".concat(n),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to create key:",e),e}},es=async e=>{try{let t=a?"".concat(a,"/global/spend/all_tag_names"):"/global/spend/all_tag_names";console.log("in global/spend/all_tag_names call",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ei=async e=>{try{let t=a?"".concat(a,"/global/all_end_users"):"/global/all_end_users";console.log("in global/all_end_users call",t);let o=await fetch("".concat(t),{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},el=async(e,t)=>{try{let o=a?"".concat(a,"/user/filter/ui"):"/user/filter/ui";t.get("user_email")&&(o+="?user_email=".concat(t.get("user_email"))),t.get("user_id")&&(o+="?user_id=".concat(t.get("user_id")));let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to create key:",e),e}},ed=async(e,t,o,r,n,c,s,i,h,p,w)=>{try{let u=a?"".concat(a,"/spend/logs/ui"):"/spend/logs/ui",g=new URLSearchParams;t&&g.append("api_key",t),o&&g.append("team_id",o),r&&g.append("request_id",r),n&&g.append("start_date",n),c&&g.append("end_date",c),s&&g.append("page",s.toString()),i&&g.append("page_size",i.toString()),h&&g.append("user_id",h),p&&g.append("status_filter",p),w&&g.append("model",w);let f=g.toString();f&&(u+="?".concat(f));let y=await fetch(u,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!y.ok){let e=await y.text();throw l(e),Error("Network response was not ok")}let m=await y.json();return console.log("Spend Logs Response:",m),m}catch(e){throw console.error("Failed to fetch spend logs:",e),e}},eh=async e=>{try{let t=a?"".concat(a,"/global/spend/logs"):"/global/spend/logs",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ep=async e=>{try{let t=a?"".concat(a,"/global/spend/keys?limit=5"):"/global/spend/keys?limit=5",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},ew=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/end_users"):"/global/spend/end_users",c="";c=t?JSON.stringify({api_key:t,startTime:o,endTime:r}):JSON.stringify({startTime:o,endTime:r});let s={method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:c},i=await fetch(n,s);if(!i.ok){let e=await i.text();throw l(e),Error("Network response was not ok")}let h=await i.json();return console.log(h),h}catch(e){throw console.error("Failed to create key:",e),e}},eu=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/spend/provider"):"/global/spend/provider";o&&r&&(n+="?start_date=".concat(o,"&end_date=").concat(r)),t&&(n+="&api_key=".concat(t));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},eg=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity"):"/global/activity";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ef=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity/cache_hits"):"/global/activity/cache_hits";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ey=async(e,t,o)=>{try{let r=a?"".concat(a,"/global/activity/model"):"/global/activity/model";t&&o&&(r+="?start_date=".concat(t,"&end_date=").concat(o));let n={method:"GET",headers:{[d]:"Bearer ".concat(e)}},c=await fetch(r,n);if(!c.ok)throw await c.text(),Error("Network response was not ok");let s=await c.json();return console.log(s),s}catch(e){throw console.error("Failed to fetch spend data:",e),e}},em=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/activity/exceptions"):"/global/activity/exceptions";t&&o&&(n+="?start_date=".concat(t,"&end_date=").concat(o)),r&&(n+="&model_group=".concat(r));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok)throw await s.text(),Error("Network response was not ok");let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},ek=async(e,t,o,r)=>{try{let n=a?"".concat(a,"/global/activity/exceptions/deployment"):"/global/activity/exceptions/deployment";t&&o&&(n+="?start_date=".concat(t,"&end_date=").concat(o)),r&&(n+="&model_group=".concat(r));let c={method:"GET",headers:{[d]:"Bearer ".concat(e)}},s=await fetch(n,c);if(!s.ok)throw await s.text(),Error("Network response was not ok");let i=await s.json();return console.log(i),i}catch(e){throw console.error("Failed to fetch spend data:",e),e}},e_=async e=>{try{let t=a?"".concat(a,"/global/spend/models?limit=5"):"/global/spend/models?limit=5",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log(r),r}catch(e){throw console.error("Failed to create key:",e),e}},eT=async(e,t)=>{try{let o=a?"".concat(a,"/v2/key/info"):"/v2/key/info",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({keys:t})});if(!r.ok){let e=await r.text();if(e.includes("Invalid proxy server token passed"))throw Error("Invalid proxy server token passed");throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to create key:",e),e}},ej=async(e,t,o)=>{try{console.log("Sending model connection test request:",JSON.stringify(t));let n=a?"".concat(a,"/health/test_connection"):"/health/test_connection",c=await fetch(n,{method:"POST",headers:{"Content-Type":"application/json",[d]:"Bearer ".concat(e)},body:JSON.stringify({litellm_params:t,mode:o})}),s=c.headers.get("content-type");if(!s||!s.includes("application/json")){let e=await c.text();throw console.error("Received non-JSON response:",e),Error("Received non-JSON response (".concat(c.status,": ").concat(c.statusText,"). Check network tab for details."))}let i=await c.json();if(!c.ok||"error"===i.status){if("error"===i.status);else{var r;return{status:"error",message:(null===(r=i.error)||void 0===r?void 0:r.message)||"Connection test failed: ".concat(c.status," ").concat(c.statusText)}}}return i}catch(e){throw console.error("Model connection test error:",e),e}},eE=async(e,t)=>{try{console.log("entering keyInfoV1Call");let o=a?"".concat(a,"/key/info"):"/key/info";o="".concat(o,"?key=").concat(t);let n=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(console.log("response",n),!n.ok){let e=await n.text();l(e),r.ZP.error("Failed to fetch key info - "+e)}let c=await n.json();return console.log("data",c),c}catch(e){throw console.error("Failed to fetch key info:",e),e}},eC=async function(e,t,o,r,n,c,s,i){let h=arguments.length>8&&void 0!==arguments[8]?arguments[8]:null,p=arguments.length>9&&void 0!==arguments[9]?arguments[9]:null;try{let w=a?"".concat(a,"/key/list"):"/key/list";console.log("in keyListCall");let u=new URLSearchParams;o&&u.append("team_id",o.toString()),t&&u.append("organization_id",t.toString()),r&&u.append("key_alias",r),c&&u.append("key_hash",c),n&&u.append("user_id",n.toString()),s&&u.append("page",s.toString()),i&&u.append("size",i.toString()),h&&u.append("sort_by",h),p&&u.append("sort_order",p),u.append("return_full_object","true"),u.append("include_team_keys","true");let g=u.toString();g&&(w+="?".concat(g));let f=await fetch(w,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!f.ok){let e=await f.text();throw l(e),Error("Network response was not ok")}let y=await f.json();return console.log("/team/list API Response:",y),y}catch(e){throw console.error("Failed to create key:",e),e}},eS=async(e,t)=>{try{let o=a?"".concat(a,"/user/get_users?role=").concat(t):"/user/get_users?role=".concat(t);console.log("in userGetAllUsersCall:",o);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to get requested models:",e),e}},eN=async e=>{try{let t=a?"".concat(a,"/user/available_roles"):"/user/available_roles",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");let r=await o.json();return console.log("response from user/available_role",r),r}catch(e){throw e}},ev=async(e,t)=>{try{if(console.log("Form Values in teamCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/team/new"):"/team/new",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eF=async(e,t)=>{try{if(console.log("Form Values in credentialCreateCall:",t),t.metadata){console.log("formValues.metadata:",t.metadata);try{t.metadata=JSON.parse(t.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let o=a?"".concat(a,"/credentials"):"/credentials",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eb=async e=>{try{let t=a?"".concat(a,"/credentials"):"/credentials";console.log("in credentialListCall");let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("/credentials API Response:",r),r}catch(e){throw console.error("Failed to create key:",e),e}},ex=async(e,t,o)=>{try{let r=a?"".concat(a,"/credentials"):"/credentials";t?r+="/by_name/".concat(t):o&&(r+="/by_model/".concat(o)),console.log("in credentialListCall");let n=await fetch(r,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("/credentials API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eO=async(e,t)=>{try{let o=a?"".concat(a,"/credentials/").concat(t):"/credentials/".concat(t);console.log("in credentialDeleteCall:",t);let r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log(n),n}catch(e){throw console.error("Failed to delete key:",e),e}},eB=async(e,t,o)=>{try{if(console.log("Form Values in credentialUpdateCall:",o),o.metadata){console.log("formValues.metadata:",o.metadata);try{o.metadata=JSON.parse(o.metadata)}catch(e){throw Error("Failed to parse metadata: "+e)}}let r=a?"".concat(a,"/credentials/").concat(t):"/credentials/".concat(t),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eP=async(e,t)=>{try{if(console.log("Form Values in keyUpdateCall:",t),t.model_tpm_limit){console.log("formValues.model_tpm_limit:",t.model_tpm_limit);try{t.model_tpm_limit=JSON.parse(t.model_tpm_limit)}catch(e){throw Error("Failed to parse model_tpm_limit: "+e)}}if(t.model_rpm_limit){console.log("formValues.model_rpm_limit:",t.model_rpm_limit);try{t.model_rpm_limit=JSON.parse(t.model_rpm_limit)}catch(e){throw Error("Failed to parse model_rpm_limit: "+e)}}let o=a?"".concat(a,"/key/update"):"/key/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("Update key Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},eG=async(e,t)=>{try{console.log("Form Values in teamUpateCall:",t);let o=a?"".concat(a,"/team/update"):"/team/update",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),r.ZP.error("Failed to update team settings: "+e),Error(e)}let c=await n.json();return console.log("Update Team Response:",c),c}catch(e){throw console.error("Failed to update team:",e),e}},eJ=async(e,t,o)=>{try{console.log("Form Values in modelUpateCall:",t);let r=a?"".concat(a,"/model/").concat(o,"/update"):"/model/".concat(o,"/update"),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error update from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("Update model Response:",c),c}catch(e){throw console.error("Failed to update model:",e),e}},eI=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let n=a?"".concat(a,"/team/member_add"):"/team/member_add",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,member:o})});if(!c.ok){var r;let e=await c.text(),t={};try{t=JSON.parse(e)}catch(t){console.warn("Failed to parse error body as JSON:",e)}let o=(null==t?void 0:null===(r=t.detail)||void 0===r?void 0:r.error)||"Failed to add team member",a=Error(o);throw a.raw=t,a}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to create key:",e),e}},eA=async(e,t,o)=>{try{console.log("Form Values in teamMemberUpdateCall:",o);let n=a?"".concat(a,"/team/member_update"):"/team/member_update",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,role:o.role,user_id:o.user_id})});if(!c.ok){var r;let e=await c.text(),t={};try{t=JSON.parse(e)}catch(t){console.warn("Failed to parse error body as JSON:",e)}let o=(null==t?void 0:null===(r=t.detail)||void 0===r?void 0:r.error)||"Failed to add team member",a=Error(o);throw a.raw=t,a}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to update team member:",e),e}},eR=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let r=a?"".concat(a,"/team/member_delete"):"/team/member_delete",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({team_id:t,...void 0!==o.user_email&&{user_email:o.user_email},...void 0!==o.user_id&&{user_id:o.user_id}})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create key:",e),e}},eU=async(e,t,o)=>{try{console.log("Form Values in teamMemberAddCall:",o);let r=a?"".concat(a,"/organization/member_add"):"/organization/member_add",n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,member:o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error(e)}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to create organization member:",e),e}},ez=async(e,t,o)=>{try{console.log("Form Values in organizationMemberDeleteCall:",o);let r=a?"".concat(a,"/organization/member_delete"):"/organization/member_delete",n=await fetch(r,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,user_id:o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to delete organization member:",e),e}},eV=async(e,t,o)=>{try{console.log("Form Values in organizationMemberUpdateCall:",o);let r=a?"".concat(a,"/organization/member_update"):"/organization/member_update",n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({organization_id:t,...o})});if(!n.ok){let e=await n.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let c=await n.json();return console.log("API Response:",c),c}catch(e){throw console.error("Failed to update organization member:",e),e}},eL=async(e,t,o)=>{try{console.log("Form Values in userUpdateUserCall:",t);let r=a?"".concat(a,"/user/update"):"/user/update",n={...t};null!==o&&(n.user_role=o),n=JSON.stringify(n);let c=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:n});if(!c.ok){let e=await c.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let s=await c.json();return console.log("API Response:",s),s}catch(e){throw console.error("Failed to create key:",e),e}},eM=async(e,t)=>{try{let o=a?"".concat(a,"/health/services?service=").concat(t):"/health/services?service=".concat(t);console.log("Checking Slack Budget Alerts service health");let n=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error(e)}let c=await n.json();return r.ZP.success("Test request to ".concat(t," made - check logs/alerts on ").concat(t," to verify")),c}catch(e){throw console.error("Failed to perform health check:",e),e}},eD=async e=>{try{let t=a?"".concat(a,"/budget/list"):"/budget/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eZ=async(e,t,o)=>{try{let t=a?"".concat(a,"/get/config/callbacks"):"/get/config/callbacks",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eH=async e=>{try{let t=a?"".concat(a,"/config/list?config_type=general_settings"):"/config/list?config_type=general_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eq=async e=>{try{let t=a?"".concat(a,"/config/pass_through_endpoint"):"/config/pass_through_endpoint",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eX=async(e,t)=>{try{let o=a?"".concat(a,"/config/field/info?field_name=").concat(t):"/config/field/info?field_name=".concat(t),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok)throw await r.text(),Error("Network response was not ok");return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},eY=async(e,t)=>{try{let o=a?"".concat(a,"/config/pass_through_endpoint"):"/config/pass_through_endpoint",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},e$=async(e,t,o)=>{try{let n=a?"".concat(a,"/config/field/update"):"/config/field/update",c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({field_name:t,field_value:o,config_type:"general_settings"})});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}let s=await c.json();return r.ZP.success("Successfully updated value!"),s}catch(e){throw console.error("Failed to set callbacks:",e),e}},eK=async(e,t)=>{try{let o=a?"".concat(a,"/config/field/delete"):"/config/field/delete",n=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({field_name:t,config_type:"general_settings"})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return r.ZP.success("Field reset on proxy"),c}catch(e){throw console.error("Failed to get callbacks:",e),e}},eW=async(e,t)=>{try{let o=a?"".concat(a,"/config/pass_through_endpoint?endpoint_id=").concat(t):"/config/pass_through_endpoint".concat(t),r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},eQ=async(e,t)=>{try{let o=a?"".concat(a,"/config/update"):"/config/update",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to set callbacks:",e),e}},e0=async e=>{try{let t=a?"".concat(a,"/health"):"/health",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to call /health:",e),e}},e1=async(e,t)=>{try{let o=a?"".concat(a,"/health?model=").concat(encodeURIComponent(t)):"/health?model=".concat(encodeURIComponent(t)),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw Error(e||"Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to call /health for model ".concat(t,":"),e),e}},e2=async e=>{try{let t=a?"".concat(a,"/cache/ping"):"/cache/ping",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error(e)}return await o.json()}catch(e){throw console.error("Failed to call /cache/ping:",e),e}},e3=async e=>{try{let t=a?"".concat(a,"/health/latest"):"/health/latest",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error(e)}return await o.json()}catch(e){throw console.error("Failed to call /health/latest:",e),e}},e4=async e=>{try{console.log("Getting proxy UI settings"),console.log("proxyBaseUrl in getProxyUISettings:",a);let t=a?"".concat(a,"/sso/get/ui_settings"):"/sso/get/ui_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok)throw await o.text(),Error("Network response was not ok");return await o.json()}catch(e){throw console.error("Failed to get callbacks:",e),e}},e5=async e=>{try{let t=a?"".concat(a,"/v2/guardrails/list"):"/v2/guardrails/list",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to get guardrails list:",e),e}},e6=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails"):"/guardrails",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({guardrail:t})});if(!r.ok){let e=await r.text();throw l(e),Error(e)}let n=await r.json();return console.log("Create guardrail response:",n),n}catch(e){throw console.error("Failed to create guardrail:",e),e}},e8=async(e,t,o)=>{try{let r=a?"".concat(a,"/spend/logs/ui/").concat(t,"?start_date=").concat(encodeURIComponent(o)):"/spend/logs/ui/".concat(t,"?start_date=").concat(encodeURIComponent(o));console.log("Fetching log details from:",r);let n=await fetch(r,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Fetched log details:",c),c}catch(e){throw console.error("Failed to fetch log details:",e),e}},e9=async e=>{try{let t=a?"".concat(a,"/get/internal_user_settings"):"/get/internal_user_settings";console.log("Fetching SSO settings from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched SSO settings:",r),r}catch(e){throw console.error("Failed to fetch SSO settings:",e),e}},e7=async(e,t)=>{try{let o=a?"".concat(a,"/update/internal_user_settings"):"/update/internal_user_settings";console.log("Updating internal user settings:",t);let n=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Updated internal user settings:",c),r.ZP.success("Internal user settings updated successfully"),c}catch(e){throw console.error("Failed to update internal user settings:",e),e}},te=async e=>{try{let t=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server";console.log("Fetching MCP servers from:",t);let o=await fetch(t,{method:s.GET,headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched MCP servers:",r),r}catch(e){throw console.error("Failed to fetch MCP servers:",e),e}},tt=async(e,t)=>{try{console.log("Form Values in createMCPServer:",t);let o=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({...t})});if(!r.ok){let e=await r.text();throw l(e),console.error("Error response from the server:",e),Error("Network response was not ok")}let n=await r.json();return console.log("API Response:",n),n}catch(e){throw console.error("Failed to create key:",e),e}},to=async(e,t)=>{try{let o=a?"".concat(a,"/v1/mcp/server"):"/v1/mcp/server",r=await fetch(o,{method:"PUT",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to update MCP server:",e),e}},tr=async(e,t)=>{try{let o=(a?"".concat(a):"")+"/v1/mcp/server/".concat(t);console.log("in deleteMCPServer:",t);let r=await fetch(o,{method:s.DELETE,headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}}catch(e){throw console.error("Failed to delete key:",e),e}},ta=async(e,t)=>{try{let o=a?"".concat(a,"/mcp-rest/tools/list?server_id=").concat(t):"/mcp-rest/tools/list?server_id=".concat(t);console.log("Fetching MCP tools from:",o);let r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Fetched MCP tools:",n),n}catch(e){throw console.error("Failed to fetch MCP tools:",e),e}},tn=async(e,t,o)=>{try{let r=a?"".concat(a,"/mcp-rest/tools/call"):"/mcp-rest/tools/call";console.log("Calling MCP tool:",t,"with arguments:",o);let n=await fetch(r,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({name:t,arguments:o})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("MCP tool call response:",c),c}catch(e){throw console.error("Failed to call MCP tool:",e),e}},tc=async(e,t)=>{try{let o=a?"".concat(a,"/tag/new"):"/tag/new",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error creating tag:",e),e}},ts=async(e,t)=>{try{let o=a?"".concat(a,"/tag/update"):"/tag/update",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error updating tag:",e),e}},ti=async(e,t)=>{try{let o=a?"".concat(a,"/tag/info"):"/tag/info",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({names:t})});if(!r.ok){let e=await r.text();return await l(e),{}}return await r.json()}catch(e){throw console.error("Error getting tag info:",e),e}},tl=async e=>{try{let t=a?"".concat(a,"/tag/list"):"/tag/list",o=await fetch(t,{method:"GET",headers:{Authorization:"Bearer ".concat(e)}});if(!o.ok){let e=await o.text();return await l(e),{}}return await o.json()}catch(e){throw console.error("Error listing tags:",e),e}},td=async(e,t)=>{try{let o=a?"".concat(a,"/tag/delete"):"/tag/delete",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({name:t})});if(!r.ok){let e=await r.text();await l(e);return}return await r.json()}catch(e){throw console.error("Error deleting tag:",e),e}},th=async e=>{try{let t=a?"".concat(a,"/get/default_team_settings"):"/get/default_team_settings";console.log("Fetching default team settings from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched default team settings:",r),r}catch(e){throw console.error("Failed to fetch default team settings:",e),e}},tp=async(e,t)=>{try{let o=a?"".concat(a,"/update/default_team_settings"):"/update/default_team_settings";console.log("Updating default team settings:",t);let n=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Updated default team settings:",c),r.ZP.success("Default team settings updated successfully"),c}catch(e){throw console.error("Failed to update default team settings:",e),e}},tw=async(e,t)=>{try{let o=a?"".concat(a,"/team/permissions_list?team_id=").concat(t):"/team/permissions_list?team_id=".concat(t),r=await fetch(o,{method:"GET",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Team permissions response:",n),n}catch(e){throw console.error("Failed to get team permissions:",e),e}},tu=async(e,t,o)=>{try{let r=a?"".concat(a,"/team/permissions_update"):"/team/permissions_update",n=await fetch(r,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({team_id:t,team_member_permissions:o})});if(!n.ok){let e=await n.text();throw l(e),Error("Network response was not ok")}let c=await n.json();return console.log("Team permissions response:",c),c}catch(e){throw console.error("Failed to update team permissions:",e),e}},tg=async(e,t)=>{try{let o=a?"".concat(a,"/spend/logs/session/ui?session_id=").concat(encodeURIComponent(t)):"/spend/logs/session/ui?session_id=".concat(encodeURIComponent(t)),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to fetch session logs:",e),e}},tf=async(e,t)=>{try{let o=a?"".concat(a,"/vector_store/new"):"/vector_store/new",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify(t)});if(!r.ok){let e=await r.json();throw Error(e.detail||"Failed to create vector store")}return await r.json()}catch(e){throw console.error("Error creating vector store:",e),e}},ty=async function(e){arguments.length>1&&void 0!==arguments[1]&&arguments[1],arguments.length>2&&void 0!==arguments[2]&&arguments[2];try{let t=a?"".concat(a,"/vector_store/list"):"/vector_store/list",o=await fetch(t,{method:"GET",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)}});if(!o.ok){let e=await o.json();throw Error(e.detail||"Failed to list vector stores")}return await o.json()}catch(e){throw console.error("Error listing vector stores:",e),e}},tm=async(e,t)=>{try{let o=a?"".concat(a,"/vector_store/delete"):"/vector_store/delete",r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json",Authorization:"Bearer ".concat(e)},body:JSON.stringify({vector_store_id:t})});if(!r.ok){let e=await r.json();throw Error(e.detail||"Failed to delete vector store")}return await r.json()}catch(e){throw console.error("Error deleting vector store:",e),e}},tk=async e=>{try{let t=a?"".concat(a,"/email/event_settings"):"/email/event_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get email event settings")}let r=await o.json();return console.log("Email event settings response:",r),r}catch(e){throw console.error("Failed to get email event settings:",e),e}},t_=async(e,t)=>{try{let o=a?"".concat(a,"/email/event_settings"):"/email/event_settings",r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Failed to update email event settings")}let n=await r.json();return console.log("Update email event settings response:",n),n}catch(e){throw console.error("Failed to update email event settings:",e),e}},tT=async e=>{try{let t=a?"".concat(a,"/email/event_settings/reset"):"/email/event_settings/reset",o=await fetch(t,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to reset email event settings")}let r=await o.json();return console.log("Reset email event settings response:",r),r}catch(e){throw console.error("Failed to reset email event settings:",e),e}},tj=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails/").concat(t):"/guardrails/".concat(t),r=await fetch(o,{method:"DELETE",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error(e)}let n=await r.json();return console.log("Delete guardrail response:",n),n}catch(e){throw console.error("Failed to delete guardrail:",e),e}},tE=async e=>{try{let t=a?"".concat(a,"/guardrails/ui/add_guardrail_settings"):"/guardrails/ui/add_guardrail_settings",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get guardrail UI settings")}let r=await o.json();return console.log("Guardrail UI settings response:",r),r}catch(e){throw console.error("Failed to get guardrail UI settings:",e),e}},tC=async e=>{try{let t=a?"".concat(a,"/guardrails/ui/provider_specific_params"):"/guardrails/ui/provider_specific_params",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Failed to get guardrail provider specific parameters")}let r=await o.json();return console.log("Guardrail provider specific params response:",r),r}catch(e){throw console.error("Failed to get guardrail provider specific parameters:",e),e}},tS=async(e,t)=>{try{let o=a?"".concat(a,"/guardrails/").concat(t,"/info"):"/guardrails/".concat(t,"/info"),r=await fetch(o,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!r.ok){let e=await r.text();throw l(e),Error("Failed to get guardrail info")}let n=await r.json();return console.log("Guardrail info response:",n),n}catch(e){throw console.error("Failed to get guardrail info:",e),e}},tN=async(e,t,o)=>{try{let r=a?"".concat(a,"/guardrails/").concat(t):"/guardrails/".concat(t),n=await fetch(r,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!n.ok){let e=await n.text();throw l(e),Error("Failed to update guardrail")}let c=await n.json();return console.log("Update guardrail response:",c),c}catch(e){throw console.error("Failed to update guardrail:",e),e}},tv=async e=>{try{let t=a?"".concat(a,"/get/sso_settings"):"/get/sso_settings";console.log("Fetching SSO configuration from:",t);let o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!o.ok){let e=await o.text();throw l(e),Error("Network response was not ok")}let r=await o.json();return console.log("Fetched SSO configuration:",r),r}catch(e){throw console.error("Failed to fetch SSO configuration:",e),e}},tF=async(e,t)=>{try{let o=a?"".concat(a,"/update/sso_settings"):"/update/sso_settings";console.log("Updating SSO configuration:",t);let r=await fetch(o,{method:"PATCH",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}let n=await r.json();return console.log("Updated SSO configuration:",n),n}catch(e){throw console.error("Failed to update SSO configuration:",e),e}},tb=async(e,t,o,r,n)=>{try{let t=a?"".concat(a,"/audit"):"/audit",o=new URLSearchParams;r&&o.append("page",r.toString()),n&&o.append("page_size",n.toString());let c=o.toString();c&&(t+="?".concat(c));let s=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"}});if(!s.ok){let e=await s.text();throw l(e),Error("Network response was not ok")}return await s.json()}catch(e){throw console.error("Failed to fetch audit logs:",e),e}},tx=async e=>{try{let t=a?"".concat(a,"/user/available_users"):"/user/available_users",o=await fetch(t,{method:"GET",headers:{[d]:"Bearer ".concat(e)}});if(!o.ok){if(404===o.status)return null;let e=await o.text();throw l(e),Error("Network response was not ok")}return await o.json()}catch(e){throw console.error("Failed to fetch remaining users:",e),e}},tO=async(e,t,o)=>{try{let n=a?"".concat(a,"/config/pass_through_endpoint/").concat(encodeURIComponent(t)):"/config/pass_through_endpoint/".concat(encodeURIComponent(t)),c=await fetch(n,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!c.ok){let e=await c.text();throw l(e),Error("Network response was not ok")}let s=await c.json();return r.ZP.success("Pass through endpoint updated successfully"),s}catch(e){throw console.error("Failed to update pass through endpoint:",e),e}},tB=async(e,t)=>{try{let o=a?"".concat(a,"/config/callback/delete"):"/config/callback/delete",r=await fetch(o,{method:"POST",headers:{[d]:"Bearer ".concat(e),"Content-Type":"application/json"},body:JSON.stringify({callback_name:t})});if(!r.ok){let e=await r.text();throw l(e),Error("Network response was not ok")}return await r.json()}catch(e){throw console.error("Failed to delete specific callback:",e),e}}}}]); \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/_next/static/chunks/webpack-a426aae3231a8df1.js b/litellm/proxy/_experimental/out/_next/static/chunks/webpack-a426aae3231a8df1.js index c82df116cd8..21702908136 100644 --- a/litellm/proxy/_experimental/out/_next/static/chunks/webpack-a426aae3231a8df1.js +++ b/litellm/proxy/_experimental/out/_next/static/chunks/webpack-a426aae3231a8df1.js @@ -1 +1 @@ -!function(){"use strict";var e,t,n,r,o,u,i,c,f,a={},l={};function d(e){var t=l[e];if(void 0!==t)return t.exports;var n=l[e]={id:e,loaded:!1,exports:{}},r=!0;try{a[e].call(n.exports,n,n.exports,d),r=!1}finally{r&&delete l[e]}return n.loaded=!0,n.exports}d.m=a,e=[],d.O=function(t,n,r,o){if(n){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[n,r,o];return}for(var i=1/0,u=0;u=o&&Object.keys(d.O).every(function(e){return d.O[e](n[f])})?n.splice(f--,1):(c=!1,o0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[n,r,o];return}for(var i=1/0,u=0;u=o&&Object.keys(d.O).every(function(e){return d.O[e](n[f])})?n.splice(f--,1):(c=!1,o - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/cerebras.svg b/litellm/proxy/_experimental/out/assets/logos/cerebras.svg index 426f6430c23..1ff347220c5 100644 --- a/litellm/proxy/_experimental/out/assets/logos/cerebras.svg +++ b/litellm/proxy/_experimental/out/assets/logos/cerebras.svg @@ -1,89 +1,89 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/deepseek.svg b/litellm/proxy/_experimental/out/assets/logos/deepseek.svg index c4754047da2..61760f13190 100644 --- a/litellm/proxy/_experimental/out/assets/logos/deepseek.svg +++ b/litellm/proxy/_experimental/out/assets/logos/deepseek.svg @@ -1,25 +1,25 @@ - - - - - - + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/perplexity-ai.svg b/litellm/proxy/_experimental/out/assets/logos/perplexity-ai.svg index e828b6dfbf1..e3a32be9809 100644 --- a/litellm/proxy/_experimental/out/assets/logos/perplexity-ai.svg +++ b/litellm/proxy/_experimental/out/assets/logos/perplexity-ai.svg @@ -1,16 +1,16 @@ - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/index.html b/litellm/proxy/_experimental/out/index.html index 986f93bb911..75df302f7a2 100644 --- a/litellm/proxy/_experimental/out/index.html +++ b/litellm/proxy/_experimental/out/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/index.txt b/litellm/proxy/_experimental/out/index.txt index ec26580f8a7..a252918fcd5 100644 --- a/litellm/proxy/_experimental/out/index.txt +++ b/litellm/proxy/_experimental/out/index.txt @@ -2,6 +2,6 @@ 3:I[21718,["665","static/chunks/3014691f-b7b79b78e27792f3.js","990","static/chunks/13b76428-ebdf3012af0e4489.js","402","static/chunks/402-239a4ed70dc393da.js","313","static/chunks/313-cf4a28394ee560d6.js","899","static/chunks/899-0459bccb48a6666d.js","539","static/chunks/539-fadc69b2ec7728b1.js","250","static/chunks/250-f07ccab57fe599d4.js","699","static/chunks/699-2e0b76ba9cd1d301.js","931","static/chunks/app/page-2905d62702267b5c.js"],"default",1] 4:I[4707,[],""] 5:I[36423,[],""] -0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] +0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"LiteLLM Dashboard"}],["$","meta","3",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","link","5",{"rel":"icon","href":"./favicon.ico"}],["$","meta","6",{"name":"next-size-adjust"}]] 1:null diff --git a/litellm/proxy/_experimental/out/model_hub.txt b/litellm/proxy/_experimental/out/model_hub.txt index cfe276eec46..a0357d122d5 100644 --- a/litellm/proxy/_experimental/out/model_hub.txt +++ b/litellm/proxy/_experimental/out/model_hub.txt @@ -2,6 +2,6 @@ 3:I[52829,["402","static/chunks/402-239a4ed70dc393da.js","313","static/chunks/313-cf4a28394ee560d6.js","250","static/chunks/250-f07ccab57fe599d4.js","699","static/chunks/699-2e0b76ba9cd1d301.js","418","static/chunks/app/model_hub/page-ce40c5a05f3174ca.js"],"default",1] 4:I[4707,[],""] 5:I[36423,[],""] -0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["model_hub",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["model_hub",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","model_hub","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] +0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["model_hub",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["model_hub",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","model_hub","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"LiteLLM Dashboard"}],["$","meta","3",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","link","5",{"rel":"icon","href":"./favicon.ico"}],["$","meta","6",{"name":"next-size-adjust"}]] 1:null diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index e63b2ae7291..00000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/onboarding.txt b/litellm/proxy/_experimental/out/onboarding.txt index 5f908c80021..d191f23e4c7 100644 --- a/litellm/proxy/_experimental/out/onboarding.txt +++ b/litellm/proxy/_experimental/out/onboarding.txt @@ -2,6 +2,6 @@ 3:I[12011,["665","static/chunks/3014691f-b7b79b78e27792f3.js","402","static/chunks/402-239a4ed70dc393da.js","899","static/chunks/899-0459bccb48a6666d.js","250","static/chunks/250-f07ccab57fe599d4.js","461","static/chunks/app/onboarding/page-e05c770288debeda.js"],"default",1] 4:I[4707,[],""] 5:I[36423,[],""] -0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["onboarding",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["onboarding",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","onboarding","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] +0:["1Uk4UFrWV9mxVjloiprrd",[[["",{"children":["onboarding",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["onboarding",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","onboarding","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/31b7f215e119031e.css","precedence":"next","crossOrigin":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/c1c21001170a99e0.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"__className_b0dd8a","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"LiteLLM Dashboard"}],["$","meta","3",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","link","5",{"rel":"icon","href":"./favicon.ico"}],["$","meta","6",{"name":"next-size-adjust"}]] 1:null diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index eac0e42ca4a..5ef1a98ef8d 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -272,26 +272,11 @@ class VertexPassthroughLoggingHandler: return None if len(parsed_chunks) == 0: return None - litellm_custom_stream_wrapper = litellm.CustomStreamWrapper( - completion_stream=vertex_iterator, - model=model, - logging_obj=litellm_logging_obj, - custom_llm_provider="vertex_ai", - ) all_openai_chunks = [] for parsed_chunk in parsed_chunks: - try: - litellm_chunk = litellm_custom_stream_wrapper.chunk_creator( - chunk=parsed_chunk - ) - except Exception as e: - verbose_proxy_logger.error( - "Error creating litellm chunk from vertex passthrough endpoint: %s", - str(e), - ) + if parsed_chunk is None: continue - if litellm_chunk is not None: - all_openai_chunks.append(litellm_chunk) + all_openai_chunks.append(parsed_chunk) complete_streaming_response = litellm.stream_chunk_builder( chunks=all_openai_chunks diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index fe0ede1726e..fda5e941484 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -442,8 +442,18 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): _parsed_body: Optional[dict] = None, litellm_call_id: Optional[str] = None, ) -> dict: + """ + Filter out litellm params from the request body + """ + from litellm.types.utils import all_litellm_params + _parsed_body = _parsed_body or {} - _litellm_metadata: Optional[dict] = _parsed_body.pop("litellm_metadata", None) + + litellm_params_in_body = {} + for k in all_litellm_params: + if k in _parsed_body: + litellm_params_in_body[k] = _parsed_body.pop(k, None) + _metadata = dict( StandardLoggingUserAPIKeyMetadata( user_api_key_hash=user_api_key_dict.api_key, @@ -457,9 +467,15 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): user_api_key_request_route=user_api_key_dict.request_route, ) ) + _metadata["user_api_key"] = user_api_key_dict.api_key - if _litellm_metadata: - _metadata.update(_litellm_metadata) + + litellm_metadata = litellm_params_in_body.pop("litellm_metadata", None) + metadata = litellm_params_in_body.pop("metadata", None) + if litellm_metadata: + _metadata.update(litellm_metadata) + if metadata: + _metadata.update(metadata) _metadata = _update_metadata_with_tags_in_header( request=request, @@ -468,12 +484,13 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): kwargs = { "litellm_params": { + **litellm_params_in_body, "metadata": _metadata, "proxy_server_request": { - "url": str(request.url), - "method": request.method, - "body": copy.copy(_parsed_body), # use copy instead of deepcopy - } + "url": str(request.url), + "method": request.method, + "body": copy.copy(_parsed_body), # use copy instead of deepcopy + }, }, "call_type": "pass_through_endpoint", "litellm_call_id": litellm_call_id, @@ -629,6 +646,7 @@ async def pass_through_request( # noqa: PLR0915 request=request, logging_obj=logging_obj, ) + # done for supporting 'parallel_request_limiter.py' with pass-through endpoints logging_obj.update_environment_variables( model="unknown", @@ -962,7 +980,7 @@ class InitPassThroughEndpointHelpers: app.add_api_route( path=path, - endpoint=create_pass_through_route( + endpoint=create_pass_through_route( # type: ignore path, target, custom_headers, @@ -996,7 +1014,7 @@ class InitPassThroughEndpointHelpers: app.add_api_route( path=wildcard_path, - endpoint=create_pass_through_route( + endpoint=create_pass_through_route( # type: ignore path, target, custom_headers, @@ -1024,6 +1042,7 @@ async def initialize_pass_through_endpoints( None """ import uuid + verbose_proxy_logger.debug("initializing pass through endpoints") from litellm.proxy._types import CommonProxyErrors, LiteLLMRoutes from litellm.proxy.proxy_server import app, premium_user @@ -1031,11 +1050,11 @@ async def initialize_pass_through_endpoints( for endpoint in pass_through_endpoints: if isinstance(endpoint, PassThroughGenericEndpoint): endpoint = endpoint.model_dump() - + # Auto-generate ID for backwards compatibility if not present if endpoint.get("id") is None: endpoint["id"] = str(uuid.uuid4()) - + _target = endpoint.get("target", None) _path: Optional[str] = endpoint.get("path", None) if _path is None: @@ -1062,7 +1081,9 @@ async def initialize_pass_through_endpoints( continue # Add exact path route - verbose_proxy_logger.debug("Initializing pass through endpoint: %s (ID: %s)", _path, endpoint.get("id")) + verbose_proxy_logger.debug( + "Initializing pass through endpoint: %s (ID: %s)", _path, endpoint.get("id") + ) InitPassThroughEndpointHelpers.add_exact_path_route( app=app, path=_path, @@ -1087,7 +1108,9 @@ async def initialize_pass_through_endpoints( cost_per_request=endpoint.get("cost_per_request", None), ) - verbose_proxy_logger.debug("Added new pass through endpoint: %s (ID: %s)", _path, endpoint.get("id")) + verbose_proxy_logger.debug( + "Added new pass through endpoint: %s (ID: %s)", _path, endpoint.get("id") + ) async def _get_pass_through_endpoints_from_db( @@ -1117,12 +1140,10 @@ async def _get_pass_through_endpoints_from_db( returned_endpoints.append(endpoint) else: # Find specific endpoint by ID - found_endpoint = _find_endpoint_by_id( - pass_through_endpoint_data, endpoint_id - ) + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) if found_endpoint is not None: returned_endpoints.append(found_endpoint) - + return returned_endpoints @@ -1182,22 +1203,22 @@ async def update_pass_through_endpoints( ) # Find the endpoint to update - found_endpoint = _find_endpoint_by_id( - pass_through_endpoint_data, endpoint_id - ) - + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) + if found_endpoint is None: raise HTTPException( status_code=404, - detail={ - "error": f"Endpoint with ID '{endpoint_id}' not found" - }, + detail={"error": f"Endpoint with ID '{endpoint_id}' not found"}, ) # Find the index for updating the list endpoint_index = None for idx, endpoint in enumerate(pass_through_endpoint_data): - _endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint + _endpoint = ( + PassThroughGenericEndpoint(**endpoint) + if isinstance(endpoint, dict) + else endpoint + ) if _endpoint.id == endpoint_id: endpoint_index = idx break @@ -1212,20 +1233,20 @@ async def update_pass_through_endpoints( # Get the update data as dict, excluding None values for partial updates update_data = data.model_dump(exclude_none=True) - + # Start with existing endpoint data endpoint_dict = found_endpoint.model_dump() - + # Update with new data (only non-None values) endpoint_dict.update(update_data) - + # Preserve existing ID if not provided in update and endpoint has ID if "id" not in update_data and found_endpoint.id is not None: endpoint_dict["id"] = found_endpoint.id - + # Create updated endpoint object updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict) - + # Update the list pass_through_endpoint_data[endpoint_index] = endpoint_dict @@ -1239,7 +1260,9 @@ async def update_pass_through_endpoints( data=updated_data, user_api_key_dict=user_api_key_dict ) - return PassThroughEndpointResponse(endpoints=[updated_endpoint] if updated_endpoint else []) + return PassThroughEndpointResponse( + endpoints=[updated_endpoint] if updated_endpoint else [] + ) @router.post( @@ -1335,10 +1358,8 @@ async def delete_pass_through_endpoints( ) # Find the endpoint to delete - found_endpoint = _find_endpoint_by_id( - pass_through_endpoint_data, endpoint_id - ) - + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) + if found_endpoint is None: raise HTTPException( status_code=400, @@ -1348,11 +1369,15 @@ async def delete_pass_through_endpoints( ) }, ) - + # Find the index for deleting from the list endpoint_index = None for idx, endpoint in enumerate(pass_through_endpoint_data): - _endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint + _endpoint = ( + PassThroughGenericEndpoint(**endpoint) + if isinstance(endpoint, dict) + else endpoint + ) if _endpoint.id == endpoint_id: endpoint_index = idx break @@ -1364,7 +1389,7 @@ async def delete_pass_through_endpoints( "error": f"Could not find index for endpoint with ID '{endpoint_id}'" }, ) - + # Remove the endpoint pass_through_endpoint_data.pop(endpoint_index) response_obj = found_endpoint @@ -1388,11 +1413,11 @@ def _find_endpoint_by_id( ) -> Optional[PassThroughGenericEndpoint]: """ Find an endpoint by ID. - + Args: endpoints_data: List of endpoint data (dicts or PassThroughGenericEndpoint objects) endpoint_id: ID to search for - + Returns: Found endpoint or None if not found """ @@ -1406,7 +1431,7 @@ def _find_endpoint_by_id( # Only compare IDs to IDs if _endpoint is not None and _endpoint.id == endpoint_id: return _endpoint - + return None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 74a1422cdbf..68d861cbc11 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -947,24 +947,6 @@ class Usage(CompletionUsage): elif isinstance(completion_tokens_details, CompletionTokensDetails): _completion_tokens_details = completion_tokens_details - ## DEEPSEEK MAPPING ## - if "prompt_cache_hit_tokens" in params and isinstance( - params["prompt_cache_hit_tokens"], int - ): - if prompt_tokens_details is None: - prompt_tokens_details = PromptTokensDetailsWrapper( - cached_tokens=params["prompt_cache_hit_tokens"] - ) - - ## ANTHROPIC MAPPING ## - if "cache_read_input_tokens" in params and isinstance( - params["cache_read_input_tokens"], int - ): - if prompt_tokens_details is None: - prompt_tokens_details = PromptTokensDetailsWrapper( - cached_tokens=params["cache_read_input_tokens"] - ) - # handle prompt_tokens_details _prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None if prompt_tokens_details: @@ -975,6 +957,28 @@ class Usage(CompletionUsage): elif isinstance(prompt_tokens_details, PromptTokensDetails): _prompt_tokens_details = prompt_tokens_details + ## DEEPSEEK MAPPING ## + if "prompt_cache_hit_tokens" in params and isinstance( + params["prompt_cache_hit_tokens"], int + ): + if _prompt_tokens_details is None: + _prompt_tokens_details = PromptTokensDetailsWrapper( + cached_tokens=params["prompt_cache_hit_tokens"] + ) + else: + _prompt_tokens_details.cached_tokens = params["prompt_cache_hit_tokens"] + + ## ANTHROPIC MAPPING ## + if "cache_read_input_tokens" in params and isinstance( + params["cache_read_input_tokens"], int + ): + if _prompt_tokens_details is None: + _prompt_tokens_details = PromptTokensDetailsWrapper( + cached_tokens=params["cache_read_input_tokens"] + ) + else: + _prompt_tokens_details.cached_tokens = params["cache_read_input_tokens"] + super().__init__( prompt_tokens=prompt_tokens or 0, completion_tokens=completion_tokens or 0, @@ -2490,6 +2494,7 @@ class DynamicPromptManagementParamLiteral(str, Enum): def list_all_params(cls): return [param.value for param in cls] + class CallbacksByType(TypedDict): success: List[str] failure: List[str] diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 600075cc7be..9645bb04f20 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -266,3 +266,62 @@ def test_default_image_cost_calculator(monkeypatch): } cost = default_image_cost_calculator(**args) assert cost == 10485760 + + +def test_cost_calculator_with_cache_creation(): + from litellm import completion_cost + from litellm.types.utils import ( + Choices, + CompletionTokensDetailsWrapper, + Message, + PromptTokensDetailsWrapper, + Usage, + ) + + litellm_model_response = ModelResponse( + id="chatcmpl-cc5638bc-fdfe-48e4-8884-57c8f4fb7c63", + created=1750733889, + model=None, + object="chat.completion", + system_fingerprint=None, + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Hello! How can I help you today?", + role="assistant", + tool_calls=None, + function_call=None, + provider_specific_fields=None, + ), + ) + ], + usage=Usage( + **{ + "total_tokens": 28508, + "prompt_tokens": 28495, + "completion_tokens": 13, + "prompt_tokens_details": {"audio_tokens": None, "cached_tokens": 0}, + "cache_read_input_tokens": 28491, + "completion_tokens_details": { + "audio_tokens": None, + "reasoning_tokens": 0, + "accepted_prediction_tokens": None, + "rejected_prediction_tokens": None, + }, + "cache_creation_input_tokens": 15, + } + ), + ) + model = "claude-sonnet-4@20250514" + + assert litellm_model_response.usage.prompt_tokens_details.cached_tokens == 28491 + + result = completion_cost( + completion_response=litellm_model_response, + model=model, + custom_llm_provider="vertex_ai", + ) + + print(result) From a89397a798c14a0eeead38e4b9ffdad53d9d6f4a Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Mon, 23 Jun 2025 22:33:06 -0700 Subject: [PATCH 27/45] Litellm dev 06 23 2025 p1 (#11989) * fix(litellm_logging.py): fix using router model id for logging calls Fixes https://github.com/BerriAI/litellm/issues/11975#issuecomment-2995882238 * test(test_litellm_logging.py): add unit test for custom price tracking * fix(vertex_ai/): don't send invalid format parameter to vertex causes calls to fail * fix(vertex_ai_context_caching.py): if cached content present and tools in message, cache tools as well gemini throws errors if tools passed in alongside cached content * test: add unit tests * fix: fix linting errors * test: test_vertex_ai_common_utils.py update test * fix(streaming_handler.py): unset response cost when creating model response --- .../docs/tutorials/claude_responses_api.md | 2 +- litellm/cost_calculator.py | 6 +- litellm/litellm_core_utils/litellm_logging.py | 12 + .../litellm_core_utils/streaming_handler.py | 19 +- litellm/llms/vertex_ai/common_utils.py | 6 + .../vertex_ai_context_caching.py | 59 +- .../llms/vertex_ai/gemini/transformation.py | 27 +- litellm/proxy/_new_secret_config.yaml | 119 +--- litellm/types/llms/vertex_ai.py | 2 +- .../test_litellm_logging.py | 36 + .../test_vertex_ai_context_caching.py | 643 ++++++++++++++++++ .../vertex_ai/test_vertex_ai_common_utils.py | 60 +- 12 files changed, 826 insertions(+), 165 deletions(-) create mode 100644 tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py diff --git a/docs/my-website/docs/tutorials/claude_responses_api.md b/docs/my-website/docs/tutorials/claude_responses_api.md index d95f75c7d74..9fe92cf11c9 100644 --- a/docs/my-website/docs/tutorials/claude_responses_api.md +++ b/docs/my-website/docs/tutorials/claude_responses_api.md @@ -47,7 +47,7 @@ curl -X POST http://0.0.0.0:4000/v1/messages \ - Setup environment variables ```bash -export ANTHROPIC_API_BASE="http://0.0.0.0:4000" +export ANTHROPIC_BASE_URL="http://0.0.0.0:4000" export ANTHROPIC_API_KEY="sk-1234" # replace with your LiteLLM key ``` diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 30658cb091a..b55da01aff1 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -666,9 +666,9 @@ def completion_cost( # noqa: PLR0915 or isinstance(completion_response, dict) ): # tts returns a custom class if isinstance(completion_response, dict): - usage_obj: Optional[ - Union[dict, Usage] - ] = completion_response.get("usage", {}) + usage_obj: Optional[Union[dict, Usage]] = ( + completion_response.get("usage", {}) + ) else: usage_obj = getattr(completion_response, "usage", {}) if isinstance(usage_obj, BaseModel) and not _is_known_usage_objects( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 72589d57e34..c608dfc8d88 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1083,6 +1083,18 @@ class Logging(LiteLLMLoggingBaseClass): used for consistent cost calculation across response headers + logging integrations. """ + if isinstance(result, BaseModel) and hasattr(result, "_hidden_params"): + hidden_params = getattr(result, "_hidden_params", {}) + if ( + "response_cost" in hidden_params + and hidden_params["response_cost"] is not None + ): # use cost if already calculated + return hidden_params["response_cost"] + elif ( + router_model_id is None and "model_id" in hidden_params + ): # use model_id if not already set + router_model_id = hidden_params["model_id"] + ## RESPONSE COST ## custom_pricing = use_custom_pricing_for_model( litellm_params=( diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 079cef4631e..529e7c8399b 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -85,9 +85,9 @@ class CustomStreamWrapper: self.system_fingerprint: Optional[str] = None self.received_finish_reason: Optional[str] = None - self.intermittent_finish_reason: Optional[ - str - ] = None # finish reasons that show up mid-stream + self.intermittent_finish_reason: Optional[str] = ( + None # finish reasons that show up mid-stream + ) self.special_tokens = [ "<|assistant|>", "<|system|>", @@ -643,6 +643,7 @@ class CustomStreamWrapper: model_response._hidden_params = { **model_response._hidden_params, **self._hidden_params, + "response_cost": None, } if ( @@ -1322,9 +1323,9 @@ class CustomStreamWrapper: _json_delta = delta.model_dump() print_verbose(f"_json_delta: {_json_delta}") if "role" not in _json_delta or _json_delta["role"] is None: - _json_delta[ - "role" - ] = "assistant" # mistral's api returns role as None + _json_delta["role"] = ( + "assistant" # mistral's api returns role as None + ) if "tool_calls" in _json_delta and isinstance( _json_delta["tool_calls"], list ): @@ -1715,9 +1716,9 @@ class CustomStreamWrapper: chunk = next(self.completion_stream) if chunk is not None and chunk != b"": print_verbose(f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk}") - processed_chunk: Optional[ - ModelResponseStream - ] = self.chunk_creator(chunk=chunk) + processed_chunk: Optional[ModelResponseStream] = ( + self.chunk_creator(chunk=chunk) + ) print_verbose( f"PROCESSED CHUNK POST CHUNK CREATOR: {processed_chunk}" ) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 23facabbf89..cceac0ea794 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -204,6 +204,7 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False): add_object_type(parameters) # Postprocessing # Filter out fields that don't exist in Schema + parameters = filter_schema_fields(parameters, valid_schema_fields) if add_property_ordering: @@ -318,6 +319,11 @@ def filter_schema_fields( k: filter_schema_fields(v, valid_fields, processed) for k, v in value.items() } + elif key == "format": + if value in {"enum", "date-time"}: + result[key] = value + else: + continue elif key == "items" and isinstance(value, dict): result[key] = filter_schema_fields(value, valid_fields, processed) elif key == "anyOf" and isinstance(value, list): diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py index 5cfb9141a55..33a480aa6bb 100644 --- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py +++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py @@ -205,6 +205,7 @@ class ContextCachingEndpoints(VertexBase): def check_and_create_cache( self, messages: List[AllMessageValues], # receives openai format messages + optional_params: dict, # cache the tools if present, in case cache content exists in messages api_key: str, api_base: Optional[str], model: str, @@ -213,7 +214,7 @@ class ContextCachingEndpoints(VertexBase): logging_obj: Logging, extra_headers: Optional[dict] = None, cached_content: Optional[str] = None, - ) -> Tuple[List[AllMessageValues], Optional[str]]: + ) -> Tuple[List[AllMessageValues], dict, Optional[str]]: """ Receives - messages: List of dict - messages in the openai format @@ -225,7 +226,16 @@ class ContextCachingEndpoints(VertexBase): Follows - https://ai.google.dev/api/caching#request-body """ if cached_content is not None: - return messages, cached_content + return messages, optional_params, cached_content + + cached_messages, non_cached_messages = separate_cached_messages( + messages=messages + ) + + if len(cached_messages) == 0: + return messages, optional_params, None + + tools = optional_params.pop("tools", None) ## AUTHORIZATION ## token, url = self._get_token_and_url_context_caching( @@ -252,15 +262,10 @@ class ContextCachingEndpoints(VertexBase): else: client = client - cached_messages, non_cached_messages = separate_cached_messages( - messages=messages - ) - - if len(cached_messages) == 0: - return messages, None - ## CHECK IF CACHED ALREADY - generated_cache_key = local_cache_obj.get_cache_key(messages=cached_messages) + generated_cache_key = local_cache_obj.get_cache_key( + messages=cached_messages, tools=tools + ) google_cache_name = self.check_cache( cache_key=generated_cache_key, client=client, @@ -270,7 +275,7 @@ class ContextCachingEndpoints(VertexBase): logging_obj=logging_obj, ) if google_cache_name: - return non_cached_messages, google_cache_name + return non_cached_messages, optional_params, google_cache_name ## TRANSFORM REQUEST cached_content_request_body = ( @@ -279,6 +284,8 @@ class ContextCachingEndpoints(VertexBase): ) ) + cached_content_request_body["tools"] = tools + ## LOGGING logging_obj.pre_call( input=messages, @@ -305,11 +312,16 @@ class ContextCachingEndpoints(VertexBase): cached_content_response_obj = VertexAICachedContentResponseObject( name=raw_response_cached.get("name"), model=raw_response_cached.get("model") ) - return (non_cached_messages, cached_content_response_obj["name"]) + return ( + non_cached_messages, + optional_params, + cached_content_response_obj["name"], + ) async def async_check_and_create_cache( self, messages: List[AllMessageValues], # receives openai format messages + optional_params: dict, # cache the tools if present, in case cache content exists in messages api_key: str, api_base: Optional[str], model: str, @@ -318,7 +330,7 @@ class ContextCachingEndpoints(VertexBase): logging_obj: Logging, extra_headers: Optional[dict] = None, cached_content: Optional[str] = None, - ) -> Tuple[List[AllMessageValues], Optional[str]]: + ) -> Tuple[List[AllMessageValues], dict, Optional[str]]: """ Receives - messages: List of dict - messages in the openai format @@ -330,14 +342,16 @@ class ContextCachingEndpoints(VertexBase): Follows - https://ai.google.dev/api/caching#request-body """ if cached_content is not None: - return messages, cached_content + return messages, optional_params, cached_content cached_messages, non_cached_messages = separate_cached_messages( messages=messages ) if len(cached_messages) == 0: - return messages, None + return messages, optional_params, None + + tools = optional_params.pop("tools", None) ## AUTHORIZATION ## token, url = self._get_token_and_url_context_caching( @@ -362,7 +376,9 @@ class ContextCachingEndpoints(VertexBase): client = client ## CHECK IF CACHED ALREADY - generated_cache_key = local_cache_obj.get_cache_key(messages=cached_messages) + generated_cache_key = local_cache_obj.get_cache_key( + messages=cached_messages, tools=tools + ) google_cache_name = await self.async_check_cache( cache_key=generated_cache_key, client=client, @@ -371,8 +387,9 @@ class ContextCachingEndpoints(VertexBase): api_base=api_base, logging_obj=logging_obj, ) + if google_cache_name: - return non_cached_messages, google_cache_name + return non_cached_messages, optional_params, google_cache_name ## TRANSFORM REQUEST cached_content_request_body = ( @@ -381,6 +398,8 @@ class ContextCachingEndpoints(VertexBase): ) ) + cached_content_request_body["tools"] = tools + ## LOGGING logging_obj.pre_call( input=messages, @@ -407,7 +426,11 @@ class ContextCachingEndpoints(VertexBase): cached_content_response_obj = VertexAICachedContentResponseObject( name=raw_response_cached.get("name"), model=raw_response_cached.get("model") ) - return (non_cached_messages, cached_content_response_obj["name"]) + return ( + non_cached_messages, + optional_params, + cached_content_response_obj["name"], + ) def get_cache(self): pass diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 39edb9642e2..85e3f15364b 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI format to Gemini format. +Transformation logic from OpenAI format to Gemini format. Why separate file? Make it easy to see how transformation works """ @@ -402,16 +402,19 @@ def sync_transform_request_body( context_caching_endpoints = ContextCachingEndpoints() if gemini_api_key is not None: - messages, cached_content = context_caching_endpoints.check_and_create_cache( - messages=messages, - api_key=gemini_api_key, - api_base=api_base, - model=model, - client=client, - timeout=timeout, - extra_headers=extra_headers, - cached_content=optional_params.pop("cached_content", None), - logging_obj=logging_obj, + messages, optional_params, cached_content = ( + context_caching_endpoints.check_and_create_cache( + messages=messages, + optional_params=optional_params, + api_key=gemini_api_key, + api_base=api_base, + model=model, + client=client, + timeout=timeout, + extra_headers=extra_headers, + cached_content=optional_params.pop("cached_content", None), + logging_obj=logging_obj, + ) ) else: # [TODO] implement context caching for gemini as well cached_content = optional_params.pop("cached_content", None) @@ -446,9 +449,11 @@ async def async_transform_request_body( if gemini_api_key is not None: ( messages, + optional_params, cached_content, ) = await context_caching_endpoints.async_check_and_create_cache( messages=messages, + optional_params=optional_params, api_key=gemini_api_key, api_base=api_base, model=model, diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index f9c4fe15e18..07e0a96f55d 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -1,119 +1,4 @@ model_list: - - model_name: codex-mini + - model_name: gemini-2.5-pro litellm_params: - model: codex-mini-latest - api_key: os.environ/OPENAI_API_KEY - - model_name: bedrock/* - litellm_params: - model: bedrock/* - - model_name: eu.anthropic.claude-3-5-sonnet-20240620-v1:0 - litellm_params: - model: eu.anthropic.claude-3-5-sonnet-20240620-v1:0 - - model_name: "gpt-4o-mini-openai" - litellm_params: - model: gpt-4o-mini - api_key: os.environ/OPENAI_API_KEY - model_info: - access_groups: ["beta-models"] # 👈 Model Access Group - - model_name: azure_ai/Phi-3-medium - litellm_params: - model: azure_ai/Phi-3-medium - api_key: os.environ/AZURE_AI_PHI_3_MEDIUM_API_KEY - api_base: os.environ/AZURE_AI_PHI_3_MEDIUM_API_BASE - - model_name: "bedrock-nova" - litellm_params: - model: us.amazon.nova-pro-v1:0 - - model_name: openrouter_model - litellm_params: - model: openrouter/openrouter_model - api_key: os.environ/OPENROUTER_API_KEY - api_base: http://0.0.0.0:8090 - - model_name: dall-e-3-azure - litellm_params: - model: azure/dall-e-3-test - api_version: "2023-12-01-preview" - api_base: os.environ/AZURE_SWEDEN_API_BASE - api_key: os.environ/AZURE_SWEDEN_API_KEY - model_info: - input_cost_per_pixel: 10 - - model_name: "claude-3-7-sonnet" - litellm_params: - model: databricks/databricks-claude-3-7-sonnet - api_key: os.environ/DATABRICKS_API_KEY - api_base: os.environ/DATABRICKS_API_BASE - - model_name: "gpt-4.1" - litellm_params: - model: azure/gpt-4.1 - api_key: os.environ/AZURE_API_KEY_REALTIME - api_base: https://krris-m2f9a9i7-eastus2.openai.azure.com/ - - model_name: "xai/*" - litellm_params: - model: xai/* - api_key: os.environ/XAI_API_KEY - - model_name: "text-embedding-ada-002" - litellm_params: - model: text-embedding-ada-002 - api_key: os.environ/OPENAI_API_KEY - - model_name: gemini/* - litellm_params: - model: gemini/* - - model_name: llama-qwen - litellm_params: - model: ollama/qwen2:0.5b - model_info: - input_cost_per_token: 0.75 - output_cost_per_token: 3 - - model_name: gpt-image-1 - litellm_params: - model: gpt-image-1 - api_key: os.environ/OPENAI_API_KEY - # drop_params: true - - model_name: "gpt-4o-batch" - litellm_params: - model: azure/gpt-4o-mini - api_base: os.environ/AZURE_API_BASE - api_key: os.environ/AZURE_API_KEY - model_info: - id: my-general-azure-deployment - mode: batch - - model_name: "gpt-4o-batch" - litellm_params: - model: azure/gpt-4o-mini - api_base: https://krris-m2f9a9i7-eastus2.openai.azure.com - api_key: 04d22fb7e9ad4d9c8afe7c6abf97a6fc - model_info: - id: my-unique-azure-deployment - mode: batch - - model_name: fake-openai-endpoint - litellm_params: - model: openai/fake - api_key: fake-key - api_base: https://exampleopenaiendpoint-production.up.railway.app/ - - model_name: "anthropic-claude-vertex" - litellm_params: - model: vertex_ai/claude-3-5-sonnet@20240620 - vertex_project: internal-litellm-local-dev - - model_name: "openai-custom/*" - litellm_params: - model: "openai/*" - api_key: os.environ/OPENAI_API_KEY_TEST - - model_name: "anthropic-claude" - litellm_params: - model: "anthropic/claude-3-5-sonnet-latest" - api_key: os.environ/ANTHROPIC_API_KEY - -general_settings: - store_model_in_db: true - store_prompts_in_spend_logs: true - token_rate_limit_type: "output" - # master_key: os.environ/PROXY_MASTER_KEY - -litellm_settings: - # cache: true - # cache_params: - # type: redis - # ttl: 600 - # password: os.environ/REDIS_PASSWORD - # supported_call_types: ["acompletion", "completion"] - callbacks: ["prometheus", "langfuse"] - + model: gemini/gemini-2.5-pro \ No newline at end of file diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index ded64cee6c3..1c3eb49fb0f 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -89,7 +89,7 @@ class SystemInstructions(TypedDict): class Schema(TypedDict, total=False): type: Literal["STRING", "INTEGER", "BOOLEAN", "NUMBER", "ARRAY", "OBJECT"] - format: str + format: Literal["enum", "date-time"] title: str description: str nullable: bool diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 9d95749827b..6687fbd0c17 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -178,3 +178,39 @@ def test_get_request_tags(): assert "test-tag" in tags assert "User-Agent: litellm" in tags assert "User-Agent: litellm/0.1.0" in tags + + +def test_response_cost_calculator_with_response_cost_in_hidden_params(logging_obj): + from litellm import Router + from litellm.litellm_core_utils.litellm_logging import Logging + + router = Router( + model_list=[ + { + "model_name": "DeepSeek-R1", + "litellm_params": { + "model": "together_ai/deepseek-ai/DeepSeek-R1", + }, + "model_info": { + "access_groups": ["agent-models"], + "supports_tool_choice": True, + "supports_function_calling": True, + "input_cost_per_token": 100, + "output_cost_per_token": 100, + }, + } + ] + ) + + mock_response = router.completion( + model="DeepSeek-R1", + messages=[{"role": "user", "content": "Hey"}], + mock_response="Hello, world!", + ) + + response_cost = logging_obj._response_cost_calculator( + result=mock_response, + ) + + assert response_cost is not None + assert response_cost > 100 diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py new file mode 100644 index 00000000000..ef3404353ae --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -0,0 +1,643 @@ +import os +import sys +from typing import List +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../..") +) # Adds the parent directory to the system path + +from litellm.litellm_core_utils.litellm_logging import Logging +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.vertex_ai.common_utils import VertexAIError +from litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching import ( + ContextCachingEndpoints, +) + + +class TestContextCachingEndpoints: + """Test class for ContextCachingEndpoints methods""" + + def setup_method(self): + """Setup for each test method""" + self.context_caching = ContextCachingEndpoints() + self.mock_logging = MagicMock(spec=Logging) + self.mock_client = MagicMock(spec=HTTPHandler) + self.mock_async_client = MagicMock(spec=AsyncHTTPHandler) + + # Sample messages for testing + self.sample_messages = [ + { + "role": "system", + "content": "You are a helpful assistant", + "cache_control": {"type": "ephemeral"}, + }, + {"role": "user", "content": "Hello, how are you?"}, + ] + + # Sample tools for testing + self.sample_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather information", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + } + ] + + self.sample_optional_params = {"tools": self.sample_tools.copy()} + + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + def test_check_and_create_cache_with_cached_content( + self, mock_cache_obj, mock_separate + ): + """Test check_and_create_cache when cached_content is provided""" + # Setup + cached_content = "cached_content_123" + optional_params = self.sample_optional_params.copy() + + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + cached_content=cached_content, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == self.sample_messages + assert returned_params == optional_params + assert returned_cache == cached_content + + # Verify mocks weren't called since we short-circuited + mock_separate.assert_not_called() + mock_cache_obj.get_cache_key.assert_not_called() + + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + def test_check_and_create_cache_no_cached_messages(self, mock_separate): + """Test check_and_create_cache when no cached messages are found""" + # Setup + mock_separate.return_value = ([], self.sample_messages) # No cached messages + optional_params = self.sample_optional_params.copy() + + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == self.sample_messages + assert returned_params == optional_params + assert returned_cache is None + + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch.object(ContextCachingEndpoints, "check_cache") + def test_check_and_create_cache_existing_cache_found( + self, mock_check_cache, mock_cache_obj, mock_separate + ): + """Test check_and_create_cache when existing cache is found""" + # Setup + cached_messages = [self.sample_messages[0]] # System message with cache_control + non_cached_messages = [self.sample_messages[1]] # User message + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_check_cache.return_value = "existing_cache_name" + + optional_params = self.sample_optional_params.copy() + + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == non_cached_messages + assert returned_params == optional_params + assert returned_cache == "existing_cache_name" + + # Verify cache key was generated with tools + mock_cache_obj.get_cache_key.assert_called_once_with( + messages=cached_messages, tools=self.sample_tools + ) + + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.transform_openai_messages_to_gemini_context_caching" + ) + @patch.object(ContextCachingEndpoints, "check_cache") + @patch.object(ContextCachingEndpoints, "_get_token_and_url_context_caching") + def test_check_and_create_cache_create_new_cache( + self, + mock_get_token_url, + mock_check_cache, + mock_transform, + mock_cache_obj, + mock_separate, + ): + """Test check_and_create_cache when creating new cache""" + # Setup + cached_messages = [self.sample_messages[0]] + non_cached_messages = [self.sample_messages[1]] + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_check_cache.return_value = None # No existing cache + mock_get_token_url.return_value = ("token", "https://test-url.com") + + mock_transform.return_value = {"model": "gemini-1.5-pro", "contents": []} + + # Mock successful HTTP response + mock_response = MagicMock() + mock_response.json.return_value = { + "name": "new_cache_name", + "model": "gemini-1.5-pro", + } + self.mock_client.post.return_value = mock_response + + optional_params = self.sample_optional_params.copy() + + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == non_cached_messages + assert returned_params == optional_params + assert returned_cache == "new_cache_name" + + # Verify HTTP request was made + self.mock_client.post.assert_called_once() + call_args = self.mock_client.post.call_args + assert "tools" in call_args.kwargs["json"] + assert call_args.kwargs["json"]["tools"] == self.sample_tools + + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch.object(ContextCachingEndpoints, "check_cache") + @patch.object(ContextCachingEndpoints, "_get_token_and_url_context_caching") + def test_check_and_create_cache_http_error( + self, mock_get_token_url, mock_check_cache, mock_cache_obj, mock_separate + ): + """Test check_and_create_cache handles HTTP errors properly""" + # Setup + cached_messages = [self.sample_messages[0]] + non_cached_messages = [self.sample_messages[1]] + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_check_cache.return_value = None + mock_get_token_url.return_value = ("token", "https://test-url.com") + + # Mock HTTP error + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + http_error = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=mock_response + ) + self.mock_client.post.side_effect = http_error + + optional_params = self.sample_optional_params.copy() + + # Execute and Assert + with pytest.raises(VertexAIError) as exc_info: + self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + assert exc_info.value.status_code == 400 + assert "Bad Request" in str(exc_info.value.message) + + @pytest.mark.asyncio + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + async def test_async_check_and_create_cache_with_cached_content( + self, mock_cache_obj, mock_separate + ): + """Test async_check_and_create_cache when cached_content is provided""" + # Setup + cached_content = "cached_content_123" + optional_params = self.sample_optional_params.copy() + + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + cached_content=cached_content, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == self.sample_messages + assert returned_params == optional_params + assert returned_cache == cached_content + + @pytest.mark.asyncio + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + async def test_async_check_and_create_cache_no_cached_messages(self, mock_separate): + """Test async_check_and_create_cache when no cached messages are found""" + # Setup + mock_separate.return_value = ([], self.sample_messages) + optional_params = self.sample_optional_params.copy() + + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == self.sample_messages + assert returned_params == optional_params + assert returned_cache is None + + @pytest.mark.asyncio + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch.object(ContextCachingEndpoints, "async_check_cache") + async def test_async_check_and_create_cache_existing_cache_found( + self, mock_async_check_cache, mock_cache_obj, mock_separate + ): + """Test async_check_and_create_cache when existing cache is found""" + # Setup + cached_messages = [self.sample_messages[0]] + non_cached_messages = [self.sample_messages[1]] + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_async_check_cache.return_value = "existing_cache_name" + + optional_params = self.sample_optional_params.copy() + + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == non_cached_messages + assert returned_params == optional_params + assert returned_cache == "existing_cache_name" + + # Verify cache key was generated with tools + mock_cache_obj.get_cache_key.assert_called_once_with( + messages=cached_messages, tools=self.sample_tools + ) + + @pytest.mark.asyncio + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.transform_openai_messages_to_gemini_context_caching" + ) + @patch.object(ContextCachingEndpoints, "async_check_cache") + @patch.object(ContextCachingEndpoints, "_get_token_and_url_context_caching") + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.get_async_httpx_client" + ) + async def test_async_check_and_create_cache_create_new_cache( + self, + mock_get_client, + mock_get_token_url, + mock_async_check_cache, + mock_transform, + mock_cache_obj, + mock_separate, + ): + """Test async_check_and_create_cache when creating new cache""" + # Setup + cached_messages = [self.sample_messages[0]] + non_cached_messages = [self.sample_messages[1]] + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_async_check_cache.return_value = None + mock_get_token_url.return_value = ("token", "https://test-url.com") + + mock_transform.return_value = {"model": "gemini-1.5-pro", "contents": []} + + # Mock successful HTTP response + mock_response = MagicMock() + mock_response.json.return_value = { + "name": "new_cache_name", + "model": "gemini-1.5-pro", + } + self.mock_async_client.post = AsyncMock(return_value=mock_response) + + optional_params = self.sample_optional_params.copy() + + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert + messages, returned_params, returned_cache = result + assert messages == non_cached_messages + assert returned_params == optional_params + assert returned_cache == "new_cache_name" + + # Verify HTTP request was made + self.mock_async_client.post.assert_called_once() + call_args = self.mock_async_client.post.call_args + assert "tools" in call_args.kwargs["json"] + assert call_args.kwargs["json"]["tools"] == self.sample_tools + + @pytest.mark.asyncio + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.local_cache_obj" + ) + @patch.object(ContextCachingEndpoints, "async_check_cache") + @patch.object(ContextCachingEndpoints, "_get_token_and_url_context_caching") + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.get_async_httpx_client" + ) + async def test_async_check_and_create_cache_timeout_error( + self, + mock_get_client, + mock_get_token_url, + mock_async_check_cache, + mock_cache_obj, + mock_separate, + ): + """Test async_check_and_create_cache handles timeout errors properly""" + # Setup + cached_messages = [self.sample_messages[0]] + non_cached_messages = [self.sample_messages[1]] + mock_separate.return_value = (cached_messages, non_cached_messages) + + mock_cache_obj.get_cache_key.return_value = "test_cache_key" + mock_async_check_cache.return_value = None + mock_get_token_url.return_value = ("token", "https://test-url.com") + + # Mock timeout error + self.mock_async_client.post = AsyncMock( + side_effect=httpx.TimeoutException("Timeout") + ) + + optional_params = self.sample_optional_params.copy() + + # Execute and Assert + with pytest.raises(VertexAIError) as exc_info: + await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + assert exc_info.value.status_code == 408 + assert "Timeout error occurred" in str(exc_info.value.message) + + def test_check_and_create_cache_tools_popped_from_optional_params(self): + """Test that tools are properly popped from optional_params when there are cached messages""" + with patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) as mock_separate: + # Mock to return cached messages so tools get popped + cached_messages = [ + self.sample_messages[0] + ] # System message with cache_control + non_cached_messages = [self.sample_messages[1]] # User message + mock_separate.return_value = (cached_messages, non_cached_messages) + + optional_params = self.sample_optional_params.copy() + original_tools = optional_params["tools"].copy() + + # Mock the check_cache to return existing cache so we don't make HTTP calls + with patch.object( + self.context_caching, "check_cache", return_value="existing_cache" + ): + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert tools were popped from optional_params + assert "tools" not in optional_params + + # But original tools should still be available for comparison + assert original_tools == self.sample_tools + + def test_check_and_create_cache_tools_not_popped_when_no_cached_messages(self): + """Test that tools are NOT popped from optional_params when there are no cached messages""" + with patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) as mock_separate: + mock_separate.return_value = ( + [], + self.sample_messages, + ) # No cached messages + + optional_params = self.sample_optional_params.copy() + original_tools = optional_params["tools"].copy() + + # Execute + result = self.context_caching.check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert tools were NOT popped from optional_params (early return) + assert "tools" in optional_params + assert optional_params["tools"] == original_tools + + @pytest.mark.asyncio + async def test_async_check_and_create_cache_tools_not_popped_when_no_cached_messages( + self, + ): + """Test that tools are NOT popped from optional_params in async version when there are no cached messages""" + with patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) as mock_separate: + mock_separate.return_value = ( + [], + self.sample_messages, + ) # No cached messages + + optional_params = self.sample_optional_params.copy() + original_tools = optional_params["tools"].copy() + + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert tools were NOT popped from optional_params (early return) + assert "tools" in optional_params + assert optional_params["tools"] == original_tools + + @pytest.mark.asyncio + async def test_async_check_and_create_cache_tools_popped_from_optional_params(self): + """Test that tools are properly popped from optional_params in async version when there are cached messages""" + with patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) as mock_separate: + # Mock to return cached messages so tools get popped + cached_messages = [ + self.sample_messages[0] + ] # System message with cache_control + non_cached_messages = [self.sample_messages[1]] # User message + mock_separate.return_value = (cached_messages, non_cached_messages) + + optional_params = self.sample_optional_params.copy() + original_tools = optional_params["tools"].copy() + + # Mock the async_check_cache to return existing cache so we don't make HTTP calls + with patch.object( + self.context_caching, "async_check_cache", return_value="existing_cache" + ): + # Execute + result = await self.context_caching.async_check_and_create_cache( + messages=self.sample_messages, + optional_params=optional_params, + api_key="test_key", + api_base=None, + model="gemini-1.5-pro", + client=self.mock_async_client, + timeout=30.0, + logging_obj=self.mock_logging, + ) + + # Assert tools were popped from optional_params + assert "tools" not in optional_params + + # But original tools should still be available for comparison + assert original_tools == self.sample_tools diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index b43e5a113cf..9ebb9004b7f 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -237,11 +237,7 @@ def test_build_vertex_schema(): }, "recursion_limit": {"type": "integer"}, "configurable": {"type": "object"}, - "run_id": { - "anyOf": [ - {"format": "uuid", "type": "string", "nullable": True} - ] - }, + "run_id": {"anyOf": [{"type": "string", "nullable": True}]}, }, "type": "object", }, @@ -627,3 +623,57 @@ def test_get_vertex_region_global_only_model( assert result == expected_region mock_is_global_only.assert_called_once_with("test-model") + + +def test_vertex_filter_format_uri(): + import json + + from litellm.llms.vertex_ai.common_utils import filter_schema_fields + + parameters = { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to fetch content from", + }, + "prompt": { + "type": "string", + "description": "The prompt to run on the fetched content", + }, + }, + "required": ["url", "prompt"], + "$schema": "http://json-schema.org/draft-07/schema#", + } + valid_schema_fields = { + "minLength", + "nullable", + "maxItems", + "required", + "default", + "items", + "propertyOrdering", + "maximum", + "properties", + "anyOf", + "description", + "minProperties", + "minimum", + "minItems", + "maxProperties", + "title", + "pattern", + "example", + "format", + "enum", + "maxLength", + "type", + } + + new_parameters = filter_schema_fields( + schema_dict=parameters, + valid_fields=valid_schema_fields, + ) + + assert "uri" not in json.dumps(new_parameters) From 4598a9bf31d59c1982f40a89593b250e68b1c754 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 23 Jun 2025 22:34:15 -0700 Subject: [PATCH 28/45] =?UTF-8?q?bump:=20version=201.73.1=20=E2=86=92=201.?= =?UTF-8?q?73.2?= 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 d8523d12840..5ecd47df50f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm" -version = "1.73.1" +version = "1.73.2" description = "Library to easily interface with LLM API providers" authors = ["BerriAI"] license = "MIT" @@ -141,7 +141,7 @@ requires = ["poetry-core", "wheel"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "1.73.1" +version = "1.73.2" version_files = [ "pyproject.toml:^version" ] From 8965cc3b6c1228192279e8c630b957f953ce3a01 Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:08:15 -0600 Subject: [PATCH 29/45] Fix unpack_defs handling of nested $ref inside anyOf items (#11964) * refactor(unpack_defs): enhance handling of schema properties and anyOf structures - Improved the unpack_defs function to handle top-level properties and nested structures more effectively. - Added recursion for items in schemas and refined the handling of anyOf branches to ensure proper unpacking of references. - Streamlined the logic for resolving $ref keys and managing nested schemas. * test(unpack_defs): add test for resolving nested $ref in anyOf items - Introduced a new test to verify that unpack_defs correctly resolves references within items of anyOf structures, addressing a specific bug scenario (Issue #11372). - The test includes a minimal schema to ensure proper unpacking and validation of the resolved items schema. * refactor(unpack_defs): implement a generic resolver for $ref entries - Redesigned the unpack_defs function to provide a more robust and dependency-free implementation for resolving all $ref entries in JSON schemas. - Introduced a depth-first traversal method that efficiently handles nested structures, including anyOf, allOf, and items, while avoiding infinite recursion. - Enhanced memory management by resolving nodes in-place without creating a full dereferenced copy, improving performance and reducing overhead. * Remove test for unpack_defs resolving nested references in anyOf items from test_utils.py * Add test for unpack_defs resolving nested references in anyOf items This commit introduces a new test to ensure that the unpack_defs function correctly resolves $ref references within items of anyOf schemas, addressing issue #11372. The test verifies that the unpacked schema contains the expected properties and structure. --- .../prompt_templates/common_utils.py | 120 ++++++++++++++---- ...llm_core_utils_prompt_templates_factory.py | 47 +++++++ tests/test_litellm/test_utils.py | 2 + 3 files changed, 141 insertions(+), 28 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index a4ba25fc466..6b70f690f99 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -16,6 +16,7 @@ from typing import ( Optional, Union, cast, + Set, ) from litellm.types.llms.openai import ( @@ -489,37 +490,100 @@ def extract_file_data(file_data: FileTypes) -> ExtractedFileData: ) -def unpack_defs(schema, defs): - properties = schema.get("properties", None) - if properties is None: - return +# --------------------------------------------------------------------------- +# Generic, dependency-free implementation of `unpack_defs` +# --------------------------------------------------------------------------- - for name, value in properties.items(): - ref_key = value.get("$ref", None) - if ref_key is not None: - ref = defs[ref_key.split("defs/")[-1]] - unpack_defs(ref, defs) - properties[name] = ref - continue - anyof = value.get("anyOf", None) - if anyof is not None: - for i, atype in enumerate(anyof): - ref_key = atype.get("$ref", None) - if ref_key is not None: - ref = defs[ref_key.split("defs/")[-1]] - unpack_defs(ref, defs) - anyof[i] = ref - continue +def unpack_defs(schema: dict, defs: dict) -> None: + """Expand *all* ``$ref`` entries pointing into ``$defs`` / ``definitions``. - items = value.get("items", None) - if items is not None: - ref_key = items.get("$ref", None) - if ref_key is not None: - ref = defs[ref_key.split("defs/")[-1]] - unpack_defs(ref, defs) - value["items"] = ref - continue + This utility walks the entire schema tree (dicts and lists) so it naturally + resolves references hidden under any keyword – ``items``, ``allOf``, + ``anyOf``, ``oneOf``, ``additionalProperties``, etc. + + It mutates *schema* in-place and does **not** return anything. The helper + keeps memory overhead low by resolving nodes as it encounters them rather + than materialising a fully dereferenced copy first. + """ + + import copy + + # Combine the defs handed down by the caller with defs/definitions found on + # the current node. Local keys shadow parent keys to match JSON-schema + # scoping rules. + root_defs: dict = { + **defs, + **schema.get("$defs", {}), + **schema.get("definitions", {}), + } + + def _walk_and_resolve(node: Any, active_defs: dict, seen: Set[int]): # type: ignore[name-defined] + """Depth-first resolver that replaces ``{"$ref": "#/defs/Foo"}`` with + the *actual* ``Foo`` schema. + """ + + # Avoid infinite recursion on self-referential schemas + if id(node) in seen: + return node + seen.add(id(node)) + + # ----------------------------- dict ----------------------------- + if isinstance(node, dict): + # --- Case 1: this node *is* a reference --- + if "$ref" in node: + ref_name = node["$ref"].split("/")[-1] + target_schema = active_defs.get(ref_name) + # Unknown reference – leave untouched + if target_schema is None: + return node + + # Merge defs from the target to capture nested definitions + child_defs = { + **active_defs, + **target_schema.get("$defs", {}), + **target_schema.get("definitions", {}), + } + + # Recursively resolve the target *copy* to avoid mutating the + # shared definition map. + resolved = _walk_and_resolve(copy.deepcopy(target_schema), child_defs, seen) + return resolved + + # --- Case 2: regular dict – recurse into its values --- + # Update defs with any nested $defs/definitions present *here*. + current_defs = { + **active_defs, + **node.get("$defs", {}), + **node.get("definitions", {}), + } + + for key, val in list(node.items()): + node[key] = _walk_and_resolve(val, current_defs, seen) + return node + + # ---------------------------- list ------------------------------ + if isinstance(node, list): + for idx, item in enumerate(node): + node[idx] = _walk_and_resolve(item, active_defs, seen) + return node + + # -------------------------- primitive --------------------------- + return node + + # Kick off traversal + resolved_root = _walk_and_resolve(schema, root_defs, set()) + # If the resolver returned a *different* dict (e.g., the root itself was a + # $ref), mirror the changes back into the original object so that callers + # holding a reference to ``schema`` see the updated structure. + if resolved_root is not schema: + schema.clear() + if isinstance(resolved_root, dict): + schema.update(resolved_root) + else: + # In the very unlikely case the root was resolved to a non-dict + # (e.g., a primitive), replace in-place via a sentinel key. + schema["__resolved_value__"] = resolved_root # type: ignore def _get_image_mime_type_from_url(url: str) -> Optional[str]: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 4210d27e8a6..56ea91aef24 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -325,3 +325,50 @@ async def test_bedrock_process_image_async_factory(): image_url=image_url, format=None ) print(f"content_block: {content_block}") + + +def test_unpack_defs_resolves_nested_ref_inside_anyof_items(): + """Ensure unpack_defs correctly resolves $ref inside items within anyOf (Issue #11372).""" + from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs + + # Define a minimal schema reproducing the bug scenario + schema = { + "type": "object", + "properties": { + "vatAmounts": { + "anyOf": [ + { # List of VatAmount + "type": "array", + "items": {"$ref": "#/$defs/VatAmount"}, + }, + {"type": "null"}, + ], + "title": "Vat Amounts", + } + }, + "$defs": { + "VatAmount": { + "type": "object", + "properties": { + "vatRate": {"type": "number"}, + "vatAmount": {"type": "number"}, + }, + "required": ["vatRate", "vatAmount"], + "title": "VatAmount", + } + }, + } + + # Perform unpacking + unpack_defs(schema, schema["$defs"]) + + # Extract the items schema after unpacking + items_schema = ( + schema["properties"]["vatAmounts"]["anyOf"][0]["items"] + ) + + # Assertions: items_schema should now be the resolved object, not an empty dict + assert isinstance(items_schema, dict), "Items schema should be a dict after unpacking" + assert items_schema.get("type") == "object" + # Ensure essential properties are present + assert set(items_schema.get("properties", {}).keys()) == {"vatRate", "vatAmount"} diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 8f043584c09..3fd1274eb19 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2011,6 +2011,8 @@ class TestProxyFunctionCalling: print(f"Could not test {model}: {e}") + + def test_register_model_with_scientific_notation(): """ Test that the register_model function can handle scientific notation in the model name. From 330cd4f2a9f3202743b49193cb0bd9ee5a4a7072 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 24 Jun 2025 09:31:33 -0700 Subject: [PATCH 30/45] docs(self_serve.md): update self serve docs to include default team / member budget information --- docs/my-website/docs/proxy/self_serve.md | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index 39db0fffdbd..30c46cfb0a1 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -161,6 +161,11 @@ Here's the available UI roles for a LiteLLM Internal User: - `internal_user`: can login, view/create/delete their own keys, view their spend. **Cannot** add new users. - `internal_user_viewer`: can login, view their own keys, view their own spend. **Cannot** create/delete keys, add new users. +**Team Roles:** + - `admin`: can add new members to the team, can control Team Permissions, can add team-only models (useful for onboarding a team's finetuned models). + - `user`: can login, view their own keys, view their own spend. **Cannot** create/delete keys (controllable via Team Permissions), add new users. + + ## Auto-add SSO users to teams This walks through setting up sso auto-add for **Okta, Google SSO** @@ -273,6 +278,60 @@ This budget does not apply to keys created under non-default teams. [**Go Here**](./team_budgets.md) +### Default Team + + + + +Go to `Internal Users` -> `Default User Settings` and set the default team to the team you just created. + +Let's also set the default models to `no-default-models`. This means a user can only create keys within a team. + + + + + + +```yaml +default_internal_user_params: # Default Params used when a new user signs in Via SSO + user_role: "internal_user" # one of "internal_user", "internal_user_viewer", + models: ["no-default-models"] # Optional[List[str]], optional): models to be used by the user + teams: # Optional[List[NewUserRequestTeam]], optional): teams to be used by the user + - team_id: "team_id_1" # Required[str]: team_id to be used by the user + user_role: "user" # Optional[str], optional): Default role in the team. Values: "user" or "admin". Defaults to "user" +``` + + + + +### Team Member Budgets + +Set a max budget for a team member. + +You can do this when creating a new team, or by updating an existing team. + + + + + + + + + +```bash +curl -X POST '/team/new' \ +-H 'Authorization: Bearer ' \ +-H 'Content-Type: application/json' \ +-D '{ + "team_alias": "team_1", + "budget_duration": "10d", + "team_member_budget": 10 +}' +``` + + + + ### Set default params for new teams When you connect litellm to your SSO provider, litellm can auto-create teams. Use this to set the default `models`, `max_budget`, `budget_duration` for these auto-created teams. From dd38e804719f7b78450425c0e6cc1eb0121129c0 Mon Sep 17 00:00:00 2001 From: shagunb-acn Date: Tue, 24 Jun 2025 22:14:35 +0530 Subject: [PATCH 31/45] #response_format NVIDIA-NIM add response_format to OpenAI parameters mapping (#12003) --- litellm/llms/nvidia_nim/chat/transformation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/llms/nvidia_nim/chat/transformation.py b/litellm/llms/nvidia_nim/chat/transformation.py index 20478afb59f..e687229949b 100644 --- a/litellm/llms/nvidia_nim/chat/transformation.py +++ b/litellm/llms/nvidia_nim/chat/transformation.py @@ -91,6 +91,7 @@ class NvidiaNimConfig(OpenAIGPTConfig): "tools", "tool_choice", "parallel_tool_calls", + "response_format", ] def map_openai_params( From a5ce1cd49b6e2975c2fdde59bf263a055916528b Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Tue, 24 Jun 2025 13:57:24 -0400 Subject: [PATCH 32/45] add azure o3-pro pricing (#11990) --- ...odel_prices_and_context_window_backup.json | 60 +++++++++++++++++++ model_prices_and_context_window.json | 60 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index df91855d257..d441115f064 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -2156,6 +2156,66 @@ "supports_tool_choice": true, "supports_native_streaming": true }, + "azure/o3-pro": { + "max_tokens": 100000, + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "input_cost_per_token": 2e-05, + "output_cost_per_token": 8e-05, + "input_cost_per_token_batches": 1e-05, + "output_cost_per_token_batches": 4e-05, + "litellm_provider": "azure", + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "azure/o3-pro-2025-06-10": { + "max_tokens": 100000, + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "input_cost_per_token": 2e-05, + "output_cost_per_token": 8e-05, + "input_cost_per_token_batches": 1e-05, + "output_cost_per_token_batches": 4e-05, + "litellm_provider": "azure", + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure/o3": { "max_tokens": 100000, "max_input_tokens": 200000, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index df91855d257..d441115f064 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -2156,6 +2156,66 @@ "supports_tool_choice": true, "supports_native_streaming": true }, + "azure/o3-pro": { + "max_tokens": 100000, + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "input_cost_per_token": 2e-05, + "output_cost_per_token": 8e-05, + "input_cost_per_token_batches": 1e-05, + "output_cost_per_token_batches": 4e-05, + "litellm_provider": "azure", + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "azure/o3-pro-2025-06-10": { + "max_tokens": 100000, + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "input_cost_per_token": 2e-05, + "output_cost_per_token": 8e-05, + "input_cost_per_token_batches": 1e-05, + "output_cost_per_token_batches": 4e-05, + "litellm_provider": "azure", + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "azure/o3": { "max_tokens": 100000, "max_input_tokens": 200000, From b9feb43dac4fd2c2085240c62ca13753b960efce Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 24 Jun 2025 13:22:56 -0700 Subject: [PATCH 33/45] [Bug Fix] SCIM - Ensure new user roles are applied (#12015) * SCIM fix new user roles * test_create_user_defaults_to_viewer * test_create_user_uses_default_internal_user_params_role * fix default user for SCIM * fix linting error --- .../management_endpoints/scim/scim_v2.py | 14 +++ .../scim/test_scim_v2_endpoints.py | 90 ++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index b88413f180e..1ef4cdc974d 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -18,6 +18,7 @@ from fastapi import ( Response, ) +import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import ( @@ -361,6 +362,18 @@ async def create_user( # Create user in database user_id = user.userName or str(uuid.uuid4()) metadata = _build_scim_metadata(user_data["given_name"], user_data["family_name"]) + + default_role: Optional[ + Literal[ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ] + ] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY + if litellm.default_internal_user_params: + default_role = litellm.default_internal_user_params.get("user_role") + new_user_request = NewUserRequest( user_id=user_id, user_email=user_data["user_email"], @@ -368,6 +381,7 @@ async def create_user( teams=user_data["teams"], metadata=metadata, auto_create_key=False, + user_role=default_role, ) # Check if user with email already exists and update if found diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index aab700e2a72..f93728b90c3 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest from fastapi import HTTPException -from litellm.proxy._types import NewUserRequest, ProxyException +from litellm.proxy._types import LitellmUserRoles, NewUserRequest, ProxyException from litellm.proxy.management_endpoints.scim.scim_v2 import ( UserProvisionerHelpers, _handle_team_membership_changes, @@ -58,6 +58,94 @@ async def test_create_user_existing_user_conflict(mocker): mocked_new_user.assert_not_called() +@pytest.mark.asyncio +async def test_create_user_defaults_to_viewer(mocker, monkeypatch): + """If no role provided, new user should default to viewer""" + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="new-user", + name=SCIMUserName(familyName="User", givenName="New"), + emails=[SCIMUserEmail(value="new@example.com")], + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + + monkeypatch.setattr( + "litellm.default_internal_user_params", None, raising=False + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id="new-user")), + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + + await create_user(user=scim_user) + + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY + + +@pytest.mark.asyncio +async def test_create_user_uses_default_internal_user_params_role(mocker, monkeypatch): + """If role is set in default_internal_user_params, new user should use that role""" + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="new-user", + name=SCIMUserName(familyName="User", givenName="New"), + emails=[SCIMUserEmail(value="new@example.com")], + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + + # Set default_internal_user_params with a specific role + default_params = { + "user_role": LitellmUserRoles.PROXY_ADMIN, + } + monkeypatch.setattr( + "litellm.default_internal_user_params", default_params, raising=False + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id="new-user")), + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + + await create_user(user=scim_user) + + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.user_role == LitellmUserRoles.PROXY_ADMIN + + @pytest.mark.asyncio async def test_handle_existing_user_by_email_no_email(mocker): """Should return None when new_user_request has no email""" From c9a8198d125ed0bd533b159e7f28f55c2a763da8 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 24 Jun 2025 13:24:23 -0700 Subject: [PATCH 34/45] docs(self_serve.md): clarify team must be created before setting as default team --- docs/my-website/docs/proxy/self_serve.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index 30c46cfb0a1..948062d7fd5 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -292,6 +292,10 @@ Let's also set the default models to `no-default-models`. This means a user can +:::info +Team must be created before setting it as the default team. +::: + ```yaml default_internal_user_params: # Default Params used when a new user signs in Via SSO user_role: "internal_user" # one of "internal_user", "internal_user_viewer", From 8da22be199a22b1a4a6b3a054d0299e242fca270 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 24 Jun 2025 13:25:17 -0700 Subject: [PATCH 35/45] docs(self_serve.md): update doc --- docs/my-website/docs/proxy/self_serve.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index 948062d7fd5..e7860b42478 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -297,12 +297,13 @@ Team must be created before setting it as the default team. ::: ```yaml -default_internal_user_params: # Default Params used when a new user signs in Via SSO - user_role: "internal_user" # one of "internal_user", "internal_user_viewer", - models: ["no-default-models"] # Optional[List[str]], optional): models to be used by the user - teams: # Optional[List[NewUserRequestTeam]], optional): teams to be used by the user - - team_id: "team_id_1" # Required[str]: team_id to be used by the user - user_role: "user" # Optional[str], optional): Default role in the team. Values: "user" or "admin". Defaults to "user" +litellm_settings: + default_internal_user_params: # Default Params used when a new user signs in Via SSO + user_role: "internal_user" # one of "internal_user", "internal_user_viewer", + models: ["no-default-models"] # Optional[List[str]], optional): models to be used by the user + teams: # Optional[List[NewUserRequestTeam]], optional): teams to be used by the user + - team_id: "team_id_1" # Required[str]: team_id to be used by the user + user_role: "user" # Optional[str], optional): Default role in the team. Values: "user" or "admin". Defaults to "user" ``` From 1467a99aab22994da726f98e4adf9f07be80089e Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 24 Jun 2025 13:45:58 -0700 Subject: [PATCH 36/45] [Fix] Magistral small system prompt diverges too much from the official recommendation (#12007) * fix mistral _get_mistral_reasoning_system_prompt * fix test_get_mistral_reasoning_system_prompt --- .../mistral/mistral_chat_transformation.py | 27 ++++++++++++++----- .../test_mistral_chat_transformation.py | 4 --- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/litellm/llms/mistral/mistral_chat_transformation.py b/litellm/llms/mistral/mistral_chat_transformation.py index 871fd8c7ea1..e281e055537 100644 --- a/litellm/llms/mistral/mistral_chat_transformation.py +++ b/litellm/llms/mistral/mistral_chat_transformation.py @@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload, cast +from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, cast, overload from litellm.litellm_core_utils.prompt_templates.common_utils import ( handle_messages_with_content_list_to_str_conversion, @@ -107,15 +107,28 @@ class MistralConfig(OpenAIGPTConfig): def _get_mistral_reasoning_system_prompt() -> str: """ Returns the system prompt for Mistral reasoning models. - Based on Mistral's documentation: https://docs.mistral.ai/capabilities/reasoning/ + Based on Mistral's documentation: https://huggingface.co/mistralai/Magistral-Small-2506 + + Mistral recommends the following system prompt for reasoning: """ - return """When solving problems, think step-by-step in tags before providing your final answer. Use the following format: + return """ + [SYSTEM_PROMPT]system_prompt + A user will ask you to solve a task. You should first draft your thinking process (inner monologue) until you have derived the final answer. Afterwards, write a self-contained summary of your thoughts (i.e. your summary should be succinct but contain all the critical steps you needed to reach the conclusion). You should use Markdown to format your response. Write both your thoughts and summary in the same language as the task posed by the user. NEVER use \boxed{} in your response. - -Your step-by-step reasoning process. Be thorough and work through the problem carefully. - + Your thinking process must follow the template below: + + Your thoughts or/and draft, like working through an exercise on scratch paper. Be as casual and as long as you want until you are confident to generate a correct answer. + -Then provide a clear, concise answer based on your reasoning.""" + Here, provide a concise summary that reflects your reasoning and presents a clear final answer to the user. Don't mention that this is a summary. + + Problem: + + [/SYSTEM_PROMPT][INST]user_message[/INST] + reasoning_traces + + assistant_response[INST]user_message[/INST] + """ def map_openai_params( self, diff --git a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py index c46413799fe..be0e73d8506 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -95,10 +95,6 @@ class TestMistralReasoningSupport: def test_get_mistral_reasoning_system_prompt(self): """Test that the reasoning system prompt is properly formatted.""" prompt = MistralConfig._get_mistral_reasoning_system_prompt() - - assert "" in prompt - assert "" in prompt - assert "step-by-step" in prompt assert isinstance(prompt, str) assert len(prompt) > 50 # Ensure it's not empty From 97da33494adbe63852544b5fe2cce6cb1f71ed0d Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:38:48 -0600 Subject: [PATCH 37/45] Refactor unpack_defs to use iterative approach instead of recursion (#12017) * Refactor unpack_defs to use iterative approach instead of recursion - Replace recursive depth-first traversal with iterative queue-based approach - Add collections.deque import for efficient queue operations - Avoid potential stack overflow issues with deeply nested schemas - Maintain same functionality while improving performance and safety * Remove unused import of Set in common_utils.py * Enhance type hinting for queue in unpack_defs function in common_utils.py * Enhance unpack_defs function to handle key validation for parent structures in common_utils.py - Added checks to ensure that the parent is a dictionary or list and that the key is of the appropriate type (string for dicts, integer for lists) before assigning the resolved schema. - This improves the robustness of the unpack_defs function when dealing with various schema structures. --- .../prompt_templates/common_utils.py | 72 +++++++++---------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 6b70f690f99..626c8b7f297 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -16,7 +16,6 @@ from typing import ( Optional, Union, cast, - Set, ) from litellm.types.llms.openai import ( @@ -508,6 +507,7 @@ def unpack_defs(schema: dict, defs: dict) -> None: """ import copy + from collections import deque # Combine the defs handed down by the caller with defs/definitions found on # the current node. Local keys shadow parent keys to match JSON-schema @@ -518,14 +518,17 @@ def unpack_defs(schema: dict, defs: dict) -> None: **schema.get("definitions", {}), } - def _walk_and_resolve(node: Any, active_defs: dict, seen: Set[int]): # type: ignore[name-defined] - """Depth-first resolver that replaces ``{"$ref": "#/defs/Foo"}`` with - the *actual* ``Foo`` schema. - """ - - # Avoid infinite recursion on self-referential schemas + # Use iterative approach with queue to avoid recursion + # Each item in queue is (node, parent_container, key/index, active_defs, seen_ids) + queue: deque[tuple[Any, Union[dict, list, None], Union[str, int, None], dict, set]] = deque([(schema, None, None, root_defs, set())]) + + while queue: + node, parent, key, active_defs, seen = queue.popleft() + + # Avoid infinite loops on self-referential schemas if id(node) in seen: - return node + continue + seen = seen.copy() # Create new set for this branch seen.add(id(node)) # ----------------------------- dict ----------------------------- @@ -536,7 +539,7 @@ def unpack_defs(schema: dict, defs: dict) -> None: target_schema = active_defs.get(ref_name) # Unknown reference – leave untouched if target_schema is None: - return node + continue # Merge defs from the target to capture nested definitions child_defs = { @@ -545,12 +548,24 @@ def unpack_defs(schema: dict, defs: dict) -> None: **target_schema.get("definitions", {}), } - # Recursively resolve the target *copy* to avoid mutating the - # shared definition map. - resolved = _walk_and_resolve(copy.deepcopy(target_schema), child_defs, seen) - return resolved + # Replace the reference with resolved copy + resolved = copy.deepcopy(target_schema) + if parent is not None and key is not None: + if isinstance(parent, dict) and isinstance(key, str): + parent[key] = resolved + elif isinstance(parent, list) and isinstance(key, int): + parent[key] = resolved + else: + # This is the root schema itself + schema.clear() + schema.update(resolved) + resolved = schema + + # Add resolved node to queue for further processing + queue.append((resolved, parent, key, child_defs, seen)) + continue - # --- Case 2: regular dict – recurse into its values --- + # --- Case 2: regular dict – process its values --- # Update defs with any nested $defs/definitions present *here*. current_defs = { **active_defs, @@ -558,32 +573,15 @@ def unpack_defs(schema: dict, defs: dict) -> None: **node.get("definitions", {}), } - for key, val in list(node.items()): - node[key] = _walk_and_resolve(val, current_defs, seen) - return node + # Add all dict values to queue + for k, v in node.items(): + queue.append((v, node, k, current_defs, seen)) # ---------------------------- list ------------------------------ - if isinstance(node, list): + elif isinstance(node, list): + # Add all list items to queue for idx, item in enumerate(node): - node[idx] = _walk_and_resolve(item, active_defs, seen) - return node - - # -------------------------- primitive --------------------------- - return node - - # Kick off traversal - resolved_root = _walk_and_resolve(schema, root_defs, set()) - # If the resolver returned a *different* dict (e.g., the root itself was a - # $ref), mirror the changes back into the original object so that callers - # holding a reference to ``schema`` see the updated structure. - if resolved_root is not schema: - schema.clear() - if isinstance(resolved_root, dict): - schema.update(resolved_root) - else: - # In the very unlikely case the root was resolved to a non-dict - # (e.g., a primitive), replace in-place via a sentinel key. - schema["__resolved_value__"] = resolved_root # type: ignore + queue.append((item, node, idx, active_defs, seen)) def _get_image_mime_type_from_url(url: str) -> Optional[str]: From 2bb8048864285c1a62fe5c89fca3bc31865d1b59 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 24 Jun 2025 15:52:43 -0700 Subject: [PATCH 38/45] [Feat] Add OpenAI Search Vector Store Operation (#12018) * add BaseVectorStoreTransformation * fix BaseVectorStoreTransformation * add OpenAIVectorStoreTransformation * fix transform * add search, asearch vector stores * add skeleton for vector store searching * fix VectorStoreSearchOptionalRequestParams * fix VectorStoreRequestUtils * fix litellm.asearch/litellm.search * fix BaseVectorStoreConfig * add vector_store_search_handler to llm http handler * use llm http handler for searching vector stores * fix base vector store config * fix vector_store_search_handler * async_vector_store_search_handler * add conftest * add BaseVectorStoreTest * move litellm.integrations.vector_store_integrations * fix working OAI OpenAIVectorStoreConfig * add Search vector store * add OpenAI Vector Stores --- .../docs/completion/knowledgebase.md | 1 + litellm/__init__.py | 1 + .../base_vector_store.py | 0 .../bedrock_vector_store.py | 409 ++++++++++++++++++ .../vector_stores/bedrock_vector_store.py | 4 +- .../custom_logger_registry.py | 4 +- litellm/litellm_core_utils/litellm_logging.py | 4 +- .../base_llm/vector_store/transformation.py | 73 ++++ litellm/llms/custom_httpx/llm_http_handler.py | 147 ++++++- .../openai/vector_stores/transformation.py | 107 +++++ litellm/types/vector_stores.py | 11 + litellm/utils.py | 22 +- litellm/vector_stores/__init__.py | 4 + litellm/vector_stores/main.py | 232 ++++++++++ litellm/vector_stores/utils.py | 28 ++ .../test_bedrock_knowledgebase_hook.py | 2 +- .../base_vector_store_test.py | 136 ++++++ tests/vector_store_tests/conftest.py | 63 +++ .../test_openai_vector_store.py | 11 + 19 files changed, 1252 insertions(+), 7 deletions(-) rename litellm/integrations/{vector_stores => vector_store_integrations}/base_vector_store.py (100%) create mode 100644 litellm/integrations/vector_store_integrations/bedrock_vector_store.py create mode 100644 litellm/llms/base_llm/vector_store/transformation.py create mode 100644 litellm/llms/openai/vector_stores/transformation.py create mode 100644 litellm/vector_stores/__init__.py create mode 100644 litellm/vector_stores/main.py create mode 100644 litellm/vector_stores/utils.py create mode 100644 tests/vector_store_tests/base_vector_store_test.py create mode 100644 tests/vector_store_tests/conftest.py create mode 100644 tests/vector_store_tests/test_openai_vector_store.py diff --git a/docs/my-website/docs/completion/knowledgebase.md b/docs/my-website/docs/completion/knowledgebase.md index 033dccea200..b3e6a06aa9a 100644 --- a/docs/my-website/docs/completion/knowledgebase.md +++ b/docs/my-website/docs/completion/knowledgebase.md @@ -17,6 +17,7 @@ LiteLLM integrates with vector stores, allowing your models to access your organ ## Supported Vector Stores - [Bedrock Knowledge Bases](https://aws.amazon.com/bedrock/knowledge-bases/) +- [OpenAI Vector Stores](https://platform.openai.com/docs/api-reference/vector-stores/search) ## Quick Start diff --git a/litellm/__init__.py b/litellm/__init__.py index 5407dd85d58..2921c0600d9 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1141,6 +1141,7 @@ from .router import Router from .assistants.main import * from .batches.main import * from .images.main import * +from .vector_stores import * from .batch_completion.main import * # type: ignore from .rerank_api.main import * from .llms.anthropic.experimental_pass_through.messages.handler import * diff --git a/litellm/integrations/vector_stores/base_vector_store.py b/litellm/integrations/vector_store_integrations/base_vector_store.py similarity index 100% rename from litellm/integrations/vector_stores/base_vector_store.py rename to litellm/integrations/vector_store_integrations/base_vector_store.py diff --git a/litellm/integrations/vector_store_integrations/bedrock_vector_store.py b/litellm/integrations/vector_store_integrations/bedrock_vector_store.py new file mode 100644 index 00000000000..a00acefb6a3 --- /dev/null +++ b/litellm/integrations/vector_store_integrations/bedrock_vector_store.py @@ -0,0 +1,409 @@ +# +-------------------------------------------------------------+ +# +# Add Bedrock Knowledge Base Context to your LLM calls +# +# +-------------------------------------------------------------+ +# Thank you users! We ❤️ you! - Krrish & Ishaan + +import json +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +import litellm +from litellm._logging import verbose_logger, verbose_proxy_logger +from litellm.integrations.custom_logger import CustomLogger +from litellm.integrations.vector_store_integrations.base_vector_store import ( + BaseVectorStore, +) +from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.types.integrations.rag.bedrock_knowledgebase import ( + BedrockKBContent, + BedrockKBGuardrailConfiguration, + BedrockKBRequest, + BedrockKBResponse, + BedrockKBRetrievalConfiguration, + BedrockKBRetrievalQuery, + BedrockKBRetrievalResult, +) +from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage +from litellm.types.utils import StandardLoggingVectorStoreRequest +from litellm.types.vector_stores import ( + VectorStoreResultContent, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import StandardCallbackDynamicParams +else: + StandardCallbackDynamicParams = Any + + +class BedrockVectorStore(BaseVectorStore, BaseAWSLLM): + CONTENT_PREFIX_STRING = "Context: \n\n" + CUSTOM_LLM_PROVIDER = "bedrock" + + def __init__( + self, + **kwargs, + ): + self.async_handler = get_async_httpx_client( + llm_provider=httpxSpecialProvider.LoggingCallback + ) + + # store kwargs as optional_params + self.optional_params = kwargs + + super().__init__(**kwargs) + BaseAWSLLM.__init__(self) + + async def async_get_chat_completion_prompt( + self, + model: str, + messages: List[AllMessageValues], + non_default_params: dict, + prompt_id: Optional[str], + prompt_variables: Optional[dict], + dynamic_callback_params: StandardCallbackDynamicParams, + litellm_logging_obj: LiteLLMLoggingObj, + tools: Optional[List[Dict]] = None, + prompt_label: Optional[str] = None, + ) -> Tuple[str, List[AllMessageValues], dict]: + """ + Retrieves the context from the Bedrock Knowledge Base and appends it to the messages. + """ + if litellm.vector_store_registry is None: + return model, messages, non_default_params + + vector_store_ids = litellm.vector_store_registry.pop_vector_store_ids_to_run( + non_default_params=non_default_params, tools=tools + ) + vector_store_request_metadata: List[StandardLoggingVectorStoreRequest] = [] + if vector_store_ids: + for vector_store_id in vector_store_ids: + start_time = datetime.now() + query = self._get_kb_query_from_messages(messages) + bedrock_kb_response = await self.make_bedrock_kb_retrieve_request( + knowledge_base_id=vector_store_id, + query=query, + non_default_params=non_default_params, + ) + verbose_logger.debug( + f"Bedrock Knowledge Base Response: {bedrock_kb_response}" + ) + + ( + context_message, + context_string, + ) = self.get_chat_completion_message_from_bedrock_kb_response( + bedrock_kb_response + ) + if context_message is not None: + messages.append(context_message) + + ################################################################################################# + ########## LOGGING for Standard Logging Payload, Langfuse, s3, LiteLLM DB etc. ################## + ################################################################################################# + vector_store_search_response: VectorStoreSearchResponse = ( + self.transform_bedrock_kb_response_to_vector_store_search_response( + bedrock_kb_response=bedrock_kb_response, query=query + ) + ) + vector_store_request_metadata.append( + StandardLoggingVectorStoreRequest( + vector_store_id=vector_store_id, + query=query, + vector_store_search_response=vector_store_search_response, + custom_llm_provider=self.CUSTOM_LLM_PROVIDER, + start_time=start_time.timestamp(), + end_time=datetime.now().timestamp(), + ) + ) + + litellm_logging_obj.model_call_details[ + "vector_store_request_metadata" + ] = vector_store_request_metadata + + return model, messages, non_default_params + + def transform_bedrock_kb_response_to_vector_store_search_response( + self, + bedrock_kb_response: BedrockKBResponse, + query: str, + ) -> VectorStoreSearchResponse: + """ + Transform a BedrockKBResponse to a VectorStoreSearchResponse + """ + retrieval_results: Optional[ + List[BedrockKBRetrievalResult] + ] = bedrock_kb_response.get("retrievalResults", None) + vector_store_search_response: VectorStoreSearchResponse = ( + VectorStoreSearchResponse(search_query=query, data=[]) + ) + if retrieval_results is None: + return vector_store_search_response + + vector_search_response_data: List[VectorStoreSearchResult] = [] + for retrieval_result in retrieval_results: + content: Optional[BedrockKBContent] = retrieval_result.get("content", None) + if content is None: + continue + content_text: Optional[str] = content.get("text", None) + if content_text is None: + continue + vector_store_search_result: VectorStoreSearchResult = ( + VectorStoreSearchResult( + score=retrieval_result.get("score", None), + content=[VectorStoreResultContent(text=content_text, type="text")], + ) + ) + vector_search_response_data.append(vector_store_search_result) + vector_store_search_response["data"] = vector_search_response_data + return vector_store_search_response + + def _get_kb_query_from_messages(self, messages: List[AllMessageValues]) -> str: + """ + Uses the text `content` field of the last message in the list of messages + """ + if len(messages) == 0: + return "" + last_message = messages[-1] + last_message_content = last_message.get("content", None) + if last_message_content is None: + return "" + if isinstance(last_message_content, str): + return last_message_content + elif isinstance(last_message_content, list): + return "\n".join([item.get("text", "") for item in last_message_content]) + return "" + + def _prepare_request( + self, + credentials: Any, + data: BedrockKBRequest, + optional_params: dict, + aws_region_name: str, + api_base: str, + extra_headers: Optional[dict] = None, + ) -> Any: + """ + Prepare a signed AWS request. + + Args: + credentials: AWS credentials + data: Request data + optional_params: Additional parameters + aws_region_name: AWS region name + api_base: Base API URL + extra_headers: Additional headers + + Returns: + AWSRequest: A signed AWS request + """ + try: + from botocore.auth import SigV4Auth + from botocore.awsrequest import AWSRequest + except ImportError: + raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") + + sigv4 = SigV4Auth(credentials, "bedrock", aws_region_name) + + encoded_data = json.dumps(data).encode("utf-8") + headers = {"Content-Type": "application/json"} + if extra_headers is not None: + headers = {"Content-Type": "application/json", **extra_headers} + + request = AWSRequest( + method="POST", url=api_base, data=encoded_data, headers=headers + ) + sigv4.add_auth(request) + if extra_headers is not None and "Authorization" in extra_headers: + # prevent sigv4 from overwriting the auth header + request.headers["Authorization"] = extra_headers["Authorization"] + + return request.prepare() + + async def make_bedrock_kb_retrieve_request( + self, + knowledge_base_id: str, + query: str, + guardrail_id: Optional[str] = None, + guardrail_version: Optional[str] = None, + next_token: Optional[str] = None, + retrieval_configuration: Optional[BedrockKBRetrievalConfiguration] = None, + non_default_params: Optional[dict] = None, + ) -> BedrockKBResponse: + """ + Make a Bedrock Knowledge Base retrieve request. + + Args: + knowledge_base_id (str): The unique identifier of the knowledge base to query + query (str): The query text to search for + guardrail_id (Optional[str]): The guardrail ID to apply + guardrail_version (Optional[str]): The version of the guardrail to apply + next_token (Optional[str]): Token for pagination + retrieval_configuration (Optional[BedrockKBRetrievalConfiguration]): Configuration for the retrieval process + + Returns: + BedrockKBRetrievalResponse: A typed response object containing the retrieval results + """ + from fastapi import HTTPException + + non_default_params = non_default_params or {} + credentials_dict: Dict[str, Any] = {} + if litellm.vector_store_registry is not None: + credentials_dict = ( + litellm.vector_store_registry.get_credentials_for_vector_store( + knowledge_base_id + ) + ) + + credentials = self.get_credentials( + aws_access_key_id=credentials_dict.get( + "aws_access_key_id", non_default_params.get("aws_access_key_id", None) + ), + aws_secret_access_key=credentials_dict.get( + "aws_secret_access_key", + non_default_params.get("aws_secret_access_key", None), + ), + aws_session_token=credentials_dict.get( + "aws_session_token", non_default_params.get("aws_session_token", None) + ), + aws_region_name=credentials_dict.get( + "aws_region_name", non_default_params.get("aws_region_name", None) + ), + aws_session_name=credentials_dict.get( + "aws_session_name", non_default_params.get("aws_session_name", None) + ), + aws_profile_name=credentials_dict.get( + "aws_profile_name", non_default_params.get("aws_profile_name", None) + ), + aws_role_name=credentials_dict.get( + "aws_role_name", non_default_params.get("aws_role_name", None) + ), + aws_web_identity_token=credentials_dict.get( + "aws_web_identity_token", + non_default_params.get("aws_web_identity_token", None), + ), + aws_sts_endpoint=credentials_dict.get( + "aws_sts_endpoint", non_default_params.get("aws_sts_endpoint", None) + ), + ) + aws_region_name = self.get_aws_region_name_for_non_llm_api_calls( + aws_region_name=credentials_dict.get( + "aws_region_name", non_default_params.get("aws_region_name", None) + ), + ) + + # Prepare request data + request_data: BedrockKBRequest = BedrockKBRequest( + retrievalQuery=BedrockKBRetrievalQuery(text=query), + ) + if next_token: + request_data["nextToken"] = next_token + if retrieval_configuration: + request_data["retrievalConfiguration"] = retrieval_configuration + if guardrail_id and guardrail_version: + request_data["guardrailConfiguration"] = BedrockKBGuardrailConfiguration( + guardrailId=guardrail_id, guardrailVersion=guardrail_version + ) + verbose_logger.debug( + f"Request Data: {json.dumps(request_data, indent=4, default=str)}" + ) + + # Prepare the request + api_base = f"https://bedrock-agent-runtime.{aws_region_name}.amazonaws.com/knowledgebases/{knowledge_base_id}/retrieve" + + prepared_request = self._prepare_request( + credentials=credentials, + data=request_data, + optional_params=self.optional_params, + aws_region_name=aws_region_name, + api_base=api_base, + ) + + verbose_proxy_logger.debug( + "Bedrock Knowledge Base request body: %s, url %s, headers: %s", + request_data, + prepared_request.url, + prepared_request.headers, + ) + + response = await self.async_handler.post( + url=prepared_request.url, + data=prepared_request.body, # type: ignore + headers=prepared_request.headers, # type: ignore + ) + + verbose_proxy_logger.debug("Bedrock Knowledge Base response: %s", response.text) + + if response.status_code == 200: + response_data = response.json() + return BedrockKBResponse(**response_data) + else: + verbose_proxy_logger.error( + "Bedrock Knowledge Base: error in response. Status code: %s, response: %s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=response.status_code, + detail={ + "error": "Error calling Bedrock Knowledge Base", + "response": response.text, + }, + ) + + @staticmethod + def get_initialized_custom_logger() -> Optional[CustomLogger]: + from litellm.litellm_core_utils.litellm_logging import ( + _init_custom_logger_compatible_class, + ) + + return _init_custom_logger_compatible_class( + logging_integration="bedrock_vector_store", + internal_usage_cache=None, + llm_router=None, + ) + + @staticmethod + def get_chat_completion_message_from_bedrock_kb_response( + response: BedrockKBResponse, + ) -> Tuple[Optional[ChatCompletionUserMessage], str]: + """ + Retrieves the context from the Bedrock Knowledge Base response and returns a ChatCompletionUserMessage object. + """ + retrieval_results: Optional[List[BedrockKBRetrievalResult]] = response.get( + "retrievalResults", None + ) + if retrieval_results is None: + return None, "" + + # string to combine the context from the knowledge base + context_string: str = BedrockVectorStore.CONTENT_PREFIX_STRING + for retrieval_result in retrieval_results: + retrieval_result_content: Optional[BedrockKBContent] = ( + retrieval_result.get("content", None) or {} + ) + if retrieval_result_content is None: + continue + retrieval_result_text: Optional[str] = retrieval_result_content.get( + "text", None + ) + if retrieval_result_text is None: + continue + context_string += retrieval_result_text + message = ChatCompletionUserMessage( + role="user", + content=context_string, + ) + return message, context_string diff --git a/litellm/integrations/vector_stores/bedrock_vector_store.py b/litellm/integrations/vector_stores/bedrock_vector_store.py index 0523dac8edd..a00acefb6a3 100644 --- a/litellm/integrations/vector_stores/bedrock_vector_store.py +++ b/litellm/integrations/vector_stores/bedrock_vector_store.py @@ -12,7 +12,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import litellm from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger -from litellm.integrations.vector_stores.base_vector_store import BaseVectorStore +from litellm.integrations.vector_store_integrations.base_vector_store import ( + BaseVectorStore, +) from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index 20dc2b0c903..1b75cc3e3df 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -32,7 +32,9 @@ from litellm.integrations.opentelemetry import OpenTelemetry from litellm.integrations.opik.opik import OpikLogger from litellm.integrations.prometheus import PrometheusLogger from litellm.integrations.s3_v2 import S3Logger -from litellm.integrations.vector_stores.bedrock_vector_store import BedrockVectorStore +from litellm.integrations.vector_store_integrations.bedrock_vector_store import ( + BedrockVectorStore, +) from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c608dfc8d88..aa0280210d4 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -54,7 +54,9 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.deepeval.deepeval import DeepEvalLogger from litellm.integrations.mlflow import MlflowLogger -from litellm.integrations.vector_stores.bedrock_vector_store import BedrockVectorStore +from litellm.integrations.vector_store_integrations.bedrock_vector_store import ( + BedrockVectorStore, +) from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import ( StandardBuiltInToolCostTracking, diff --git a/litellm/llms/base_llm/vector_store/transformation.py b/litellm/llms/base_llm/vector_store/transformation.py new file mode 100644 index 00000000000..4df63bd2f96 --- /dev/null +++ b/litellm/llms/base_llm/vector_store/transformation.py @@ -0,0 +1,73 @@ +from abc import abstractmethod +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +import httpx + +from litellm.types.router import GenericLiteLLMParams +from litellm.types.vector_stores import ( + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + from ..chat.transformation import BaseLLMException as _BaseLLMException + + LiteLLMLoggingObj = _LiteLLMLoggingObj + BaseLLMException = _BaseLLMException +else: + LiteLLMLoggingObj = Any + BaseLLMException = Any + +class BaseVectorStoreConfig: + @abstractmethod + def transform_search_vector_store_request( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + pass + + @abstractmethod + def transform_search_vector_store_response(self, response: httpx.Response) -> VectorStoreSearchResponse: + pass + + + @abstractmethod + def validate_environment( + self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + return {} + + @abstractmethod + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + OPTIONAL + + Get the complete url for the request + + Some providers need `model` in `api_base` + """ + if api_base is None: + raise ValueError("api_base is required") + return api_base + + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + from ..chat.transformation import BaseLLMException + + raise BaseLLMException( + status_code=status_code, + message=error_message, + headers=headers, + ) + diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 4ed0016d8b2..dfc5ae5277f 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -35,6 +35,7 @@ from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -60,6 +61,10 @@ from litellm.types.rerank import OptionalRerankParams, RerankResponse from litellm.types.responses.main import DeleteResponseResult from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import EmbeddingResponse, FileTypes, TranscriptionResponse +from litellm.types.vector_stores import ( + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, +) from litellm.utils import ( CustomStreamWrapper, ImageResponse, @@ -2342,7 +2347,7 @@ class BaseLLMHTTPHandler: self, e: Exception, provider_config: Union[ - BaseConfig, BaseRerankConfig, BaseResponsesAPIConfig, BaseImageEditConfig + BaseConfig, BaseRerankConfig, BaseResponsesAPIConfig, BaseImageEditConfig, BaseVectorStoreConfig ], ): status_code = getattr(e, "status_code", 500) @@ -2613,3 +2618,143 @@ class BaseLLMHTTPHandler: raw_response=response, logging_obj=logging_obj, ) + + ###### VECTOR STORE HANDLER ###### + async def async_vector_store_search_handler( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + vector_store_provider_config: BaseVectorStoreConfig, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + _is_async: bool = False, + ) -> VectorStoreSearchResponse: + if client is None or not isinstance(client, AsyncHTTPHandler): + async_httpx_client = get_async_httpx_client( + llm_provider=litellm.LlmProviders(custom_llm_provider), + params={"ssl_verify": litellm_params.get("ssl_verify", None)}, + ) + else: + async_httpx_client = client + + headers = vector_store_provider_config.validate_environment( + headers=extra_headers or {}, + litellm_params=litellm_params + ) + + if extra_headers: + headers.update(extra_headers) + + api_base = vector_store_provider_config.get_complete_url( + api_base=litellm_params.api_base, + litellm_params=dict(litellm_params), + ) + + url, request_body = vector_store_provider_config.transform_search_vector_store_request( + vector_store_id=vector_store_id, + query=query, + vector_store_search_optional_params=vector_store_search_optional_params, + api_base=api_base, + ) + + logging_obj.pre_call( + input="", + api_key="", + additional_args={ + "complete_input_dict": request_body, + "api_base": api_base, + "headers": headers, + }, + ) + + try: + response = await async_httpx_client.post(url=url, headers=headers, json=request_body, timeout=timeout) + except Exception as e: + raise self._handle_error(e=e, provider_config=vector_store_provider_config) + + return vector_store_provider_config.transform_search_vector_store_response( + response=response, + ) + + def vector_store_search_handler( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + vector_store_provider_config: BaseVectorStoreConfig, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + _is_async: bool = False, + ) -> Union[VectorStoreSearchResponse, Coroutine[Any, Any, VectorStoreSearchResponse]]: + if _is_async: + return self.async_vector_store_search_handler( + vector_store_id=vector_store_id, + query=query, + vector_store_search_optional_params=vector_store_search_optional_params, + vector_store_provider_config=vector_store_provider_config, + litellm_params=litellm_params, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + client=client, + ) + + if client is None or not isinstance(client, HTTPHandler): + sync_httpx_client = _get_httpx_client( + params={"ssl_verify": litellm_params.get("ssl_verify", None)} + ) + else: + sync_httpx_client = client + + headers = vector_store_provider_config.validate_environment( + headers=extra_headers or {}, + litellm_params=litellm_params + ) + + if extra_headers: + headers.update(extra_headers) + + api_base = vector_store_provider_config.get_complete_url( + api_base=litellm_params.api_base, + litellm_params=dict(litellm_params), + ) + + url, request_body = vector_store_provider_config.transform_search_vector_store_request( + vector_store_id=vector_store_id, + query=query, + vector_store_search_optional_params=vector_store_search_optional_params, + api_base=api_base, + ) + + logging_obj.pre_call( + input="", + api_key="", + additional_args={ + "complete_input_dict": request_body, + "api_base": api_base, + "headers": headers, + }, + ) + + try: + response = sync_httpx_client.post(url=url, headers=headers, json=request_body) + except Exception as e: + raise self._handle_error(e=e, provider_config=vector_store_provider_config) + + return vector_store_provider_config.transform_search_vector_store_response( + response=response, + ) + diff --git a/litellm/llms/openai/vector_stores/transformation.py b/litellm/llms/openai/vector_stores/transformation.py new file mode 100644 index 00000000000..98657825684 --- /dev/null +++ b/litellm/llms/openai/vector_stores/transformation.py @@ -0,0 +1,107 @@ +from typing import Dict, List, Optional, Tuple, Union, cast + +import httpx + +import litellm +from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.vector_stores import ( + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchRequest, + VectorStoreSearchResponse, +) + + +class OpenAIVectorStoreConfig(BaseVectorStoreConfig): + ASSISTANTS_HEADER_KEY = "OpenAI-Beta" + ASSISTANTS_HEADER_VALUE = "assistants=v2" + + def validate_environment( + self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() + api_key = ( + litellm_params.api_key + or litellm.api_key + or litellm.openai_key + or get_secret_str("OPENAI_API_KEY") + ) + headers.update( + { + "Authorization": f"Bearer {api_key}", + } + ) + + ######################################################### + # Ensure OpenAI Assistants header is includes + ######################################################### + if self.ASSISTANTS_HEADER_KEY not in headers: + headers.update( + { + self.ASSISTANTS_HEADER_KEY: self.ASSISTANTS_HEADER_VALUE, + } + ) + + return headers + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the Base endpoint for OpenAI Vector Stores API + """ + api_base = ( + api_base + or litellm.api_base + or get_secret_str("OPENAI_BASE_URL") + or get_secret_str("OPENAI_API_BASE") + or "https://api.openai.com/v1" + ) + + # Remove trailing slashes + api_base = api_base.rstrip("/") + + return f"{api_base}/vector_stores" + + + def transform_search_vector_store_request( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + url = f"{api_base}/{vector_store_id}/search" + typed_request_body = VectorStoreSearchRequest( + query=query, + filters=vector_store_search_optional_params.get("filters", None), + max_num_results=vector_store_search_optional_params.get("max_num_results", None), + ranking_options=vector_store_search_optional_params.get("ranking_options", None), + rewrite_query=vector_store_search_optional_params.get("rewrite_query", None), + ) + + dict_request_body = cast(dict, typed_request_body) + return url, dict_request_body + + + + def transform_search_vector_store_response(self, response: httpx.Response) -> VectorStoreSearchResponse: + try: + response_json = response.json() + return VectorStoreSearchResponse( + **response_json + ) + except Exception as e: + raise self.get_error_class( + error_message=str(e), + status_code=response.status_code, + headers=response.headers + ) + + + + + \ No newline at end of file diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index cd8280ac960..0fcf7a2ab00 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -85,3 +85,14 @@ class VectorStoreSearchResponse(TypedDict, total=False): ] # Always "vector_store.search_results.page" search_query: Optional[str] data: Optional[List[VectorStoreSearchResult]] + +class VectorStoreSearchOptionalRequestParams(TypedDict, total=False): + """TypedDict for Optional parameters supported by the vector store search API.""" + filters: Optional[Dict] + max_num_results: Optional[int] + ranking_options: Optional[Dict] + rewrite_query: Optional[bool] + +class VectorStoreSearchRequest(VectorStoreSearchOptionalRequestParams, total=False): + """Request body for searching a vector store""" + query: Union[str, List[str]] \ No newline at end of file diff --git a/litellm/utils.py b/litellm/utils.py index dacc1680458..25f46d4bc5c 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -78,7 +78,9 @@ from litellm.constants import ( ) from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger -from litellm.integrations.vector_stores.base_vector_store import BaseVectorStore +from litellm.integrations.vector_store_integrations.base_vector_store import ( + BaseVectorStore, +) from litellm.litellm_core_utils.core_helpers import ( map_finish_reason, process_response_headers, @@ -242,6 +244,7 @@ from litellm.llms.base_llm.image_variations.transformation import ( from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from ._logging import _is_debugging_on, verbose_logger from .caching.caching import ( @@ -6902,13 +6905,28 @@ class ProviderConfigManager: def get_provider_vector_store_config( provider: LlmProviders, ) -> Optional[CustomLogger]: - from litellm.integrations.vector_stores.bedrock_vector_store import ( + from litellm.integrations.vector_store_integrations.bedrock_vector_store import ( BedrockVectorStore, ) if LlmProviders.BEDROCK == provider: return BedrockVectorStore.get_initialized_custom_logger() return None + + + @staticmethod + def get_provider_vector_stores_config( + provider: LlmProviders, + ) -> Optional[BaseVectorStoreConfig]: + """ + v2 vector store config, use this for new vector store integrations + """ + if litellm.LlmProviders.OPENAI == provider: + from litellm.llms.openai.vector_stores.transformation import ( + OpenAIVectorStoreConfig, + ) + return OpenAIVectorStoreConfig() + return None @staticmethod def get_provider_image_generation_config( diff --git a/litellm/vector_stores/__init__.py b/litellm/vector_stores/__init__.py new file mode 100644 index 00000000000..6546f9159e0 --- /dev/null +++ b/litellm/vector_stores/__init__.py @@ -0,0 +1,4 @@ +from .main import asearch, search +from .vector_store_registry import VectorStoreRegistry + +__all__ = ["search", "asearch", "VectorStoreRegistry"] diff --git a/litellm/vector_stores/main.py b/litellm/vector_stores/main.py new file mode 100644 index 00000000000..245a3f6fa3d --- /dev/null +++ b/litellm/vector_stores/main.py @@ -0,0 +1,232 @@ +""" +LiteLLM SDK Functions for Creating and Searching Vector Stores +""" +import asyncio +import contextvars +from functools import partial +from typing import Any, Coroutine, Dict, List, Optional, Union + +import httpx + +import litellm +from litellm.constants import request_timeout +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.types.router import GenericLiteLLMParams +from litellm.types.vector_stores import ( + VectorStoreResultContent, + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) +from litellm.utils import ProviderConfigManager, client +from litellm.vector_stores.utils import VectorStoreRequestUtils + +####### ENVIRONMENT VARIABLES ################### +# Initialize any necessary instances or variables here +base_llm_http_handler = BaseLLMHTTPHandler() +################################################# + + +def mock_vector_store_search_response( + mock_results: Optional[List[VectorStoreSearchResult]] = None, +): + """Mock response for vector store search""" + if mock_results is None: + mock_results = [ + VectorStoreSearchResult( + score=0.95, + content=[ + VectorStoreResultContent( + text="This is a sample search result from the vector store.", + type="text" + ) + ] + ) + ] + + return VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query="sample query", + data=mock_results, + ) + + +@client +async def asearch( + vector_store_id: str, + query: Union[str, List[str]], + filters: Optional[Dict] = None, + max_num_results: Optional[int] = None, + ranking_options: Optional[Dict] = None, + rewrite_query: Optional[bool] = None, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Optional[Dict[str, Any]] = None, + extra_query: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + # LiteLLM specific params, + custom_llm_provider: Optional[str] = None, + **kwargs, +) -> VectorStoreSearchResponse: + """ + Async: Search a vector store for relevant chunks based on a query and file attributes filter. + """ + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["asearch"] = True + + # get custom llm provider so we can use this for mapping exceptions + if custom_llm_provider is None: + custom_llm_provider = "openai" # Default to OpenAI for vector stores + + func = partial( + search, + vector_store_id=vector_store_id, + query=query, + filters=filters, + max_num_results=max_num_results, + ranking_options=ranking_options, + rewrite_query=rewrite_query, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + **kwargs, + ) + + ctx = contextvars.copy_context() + func_with_context = partial(ctx.run, func) + init_response = await loop.run_in_executor(None, func_with_context) + + if asyncio.iscoroutine(init_response): + response = await init_response + else: + response = init_response + + return response + except Exception as e: + raise litellm.exception_type( + model=None, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def search( + vector_store_id: str, + query: Union[str, List[str]], + filters: Optional[Dict] = None, + max_num_results: Optional[int] = None, + ranking_options: Optional[Dict] = None, + rewrite_query: Optional[bool] = None, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Optional[Dict[str, Any]] = None, + extra_query: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + # LiteLLM specific params, + custom_llm_provider: Optional[str] = None, + **kwargs, +) -> Union[VectorStoreSearchResponse, Coroutine[Any, Any, VectorStoreSearchResponse]]: + """ + Search a vector store for relevant chunks based on a query and file attributes filter. + + Args: + vector_store_id: The ID of the vector store to search. + query: A query string or array for the search. + filters: Optional filter to apply based on file attributes. + max_num_results: Maximum number of results to return (1-50, default 10). + ranking_options: Optional ranking options for search. + rewrite_query: Whether to rewrite the natural language query for vector search. + + Returns: + VectorStoreSearchResponse containing the search results. + """ + local_vars = locals() + try: + litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore + litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) + _is_async = kwargs.pop("asearch", False) is True + + # get llm provider logic + litellm_params = GenericLiteLLMParams(**kwargs) + + ## MOCK RESPONSE LOGIC + if litellm_params.mock_response and isinstance( + litellm_params.mock_response, (str, list) + ): + mock_results = None + if isinstance(litellm_params.mock_response, list): + mock_results = litellm_params.mock_response + return mock_vector_store_search_response(mock_results=mock_results) + + # Default to OpenAI for vector stores + if custom_llm_provider is None: + custom_llm_provider = "openai" + + # get provider config - using vector store custom logger for now + vector_store_provider_config = ProviderConfigManager.get_provider_vector_stores_config( + provider=litellm.LlmProviders(custom_llm_provider), + ) + + if vector_store_provider_config is None: + raise ValueError( + f"Vector store search is not supported for {custom_llm_provider}" + ) + + local_vars.update(kwargs) + + # Get VectorStoreSearchOptionalRequestParams with only valid parameters + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams = ( + VectorStoreRequestUtils.get_requested_vector_store_search_optional_param( + local_vars + ) + ) + + # Pre Call logging + litellm_logging_obj.update_environment_variables( + model=None, + optional_params={ + "vector_store_id": vector_store_id, + "query": query, + **vector_store_search_optional_params, + }, + litellm_params={ + "litellm_call_id": litellm_call_id, + "vector_store_id": vector_store_id, + }, + custom_llm_provider=custom_llm_provider, + ) + + response = base_llm_http_handler.vector_store_search_handler( + vector_store_id=vector_store_id, + query=query, + vector_store_search_optional_params=vector_store_search_optional_params, + vector_store_provider_config=vector_store_provider_config, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + logging_obj=litellm_logging_obj, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout or request_timeout, + _is_async=_is_async, + client=kwargs.get("client"), + ) + + return response + except Exception as e: + raise litellm.exception_type( + model=None, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) \ No newline at end of file diff --git a/litellm/vector_stores/utils.py b/litellm/vector_stores/utils.py new file mode 100644 index 00000000000..7817526d00e --- /dev/null +++ b/litellm/vector_stores/utils.py @@ -0,0 +1,28 @@ +from typing import Any, Dict, cast, get_type_hints + +from litellm.types.vector_stores import VectorStoreSearchOptionalRequestParams + + +class VectorStoreRequestUtils: + """Helper utils for constructing Vector Store search requests""" + + @staticmethod + def get_requested_vector_store_search_optional_param( + params: Dict[str, Any], + ) -> VectorStoreSearchOptionalRequestParams: + """ + Filter parameters to only include those defined in VectorStoreSearchOptionalRequestParams. + + Args: + params: Dictionary of parameters to filter + + Returns: + VectorStoreSearchOptionalRequestParams instance with only the valid parameters + """ + valid_keys = get_type_hints(VectorStoreSearchOptionalRequestParams).keys() + filtered_params = { + k: v for k, v in params.items() if k in valid_keys and v is not None + } + + return cast(VectorStoreSearchOptionalRequestParams, filtered_params) + diff --git a/tests/logging_callback_tests/test_bedrock_knowledgebase_hook.py b/tests/logging_callback_tests/test_bedrock_knowledgebase_hook.py index 532a5c11c52..4c75d485277 100644 --- a/tests/logging_callback_tests/test_bedrock_knowledgebase_hook.py +++ b/tests/logging_callback_tests/test_bedrock_knowledgebase_hook.py @@ -19,7 +19,7 @@ import pytest import litellm from litellm import completion from litellm._logging import verbose_logger -from litellm.integrations.vector_stores.bedrock_vector_store import BedrockVectorStore +from litellm.integrations.vector_store_integrations.bedrock_vector_store import BedrockVectorStore from litellm.llms.custom_httpx.http_handler import HTTPHandler, AsyncHTTPHandler from litellm.integrations.custom_logger import CustomLogger from litellm.types.utils import StandardLoggingPayload, StandardLoggingVectorStoreRequest diff --git a/tests/vector_store_tests/base_vector_store_test.py b/tests/vector_store_tests/base_vector_store_test.py new file mode 100644 index 00000000000..d23012847bb --- /dev/null +++ b/tests/vector_store_tests/base_vector_store_test.py @@ -0,0 +1,136 @@ +import httpx +import json +import pytest +import sys +from typing import Any, Dict, List +from unittest.mock import MagicMock, Mock, patch +import os +import uuid +import time +import base64 + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path +import litellm +from abc import ABC, abstractmethod +from litellm.integrations.custom_logger import CustomLogger +import json +from litellm.types.utils import StandardLoggingPayload + +class BaseVectorStoreTest(ABC): + """ + Abstract base test class that enforces a common test across all test classes. + """ + @abstractmethod + def get_base_request_args(self) -> dict: + """Must return the base request args""" + pass + + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_basic_search_vector_store(self, sync_mode): + litellm._turn_on_debug() + litellm.set_verbose = True + base_request_args = self.get_base_request_args() + try: + if sync_mode: + response = litellm.vector_stores.search( + query="Basic ping", + **base_request_args + ) + else: + response = await litellm.vector_stores.asearch( + query="Basic ping", + **base_request_args + ) + except litellm.InternalServerError: + pytest.skip("Skipping test due to litellm.InternalServerError") + + print("litellm response=", json.dumps(response, indent=4, default=str)) + + # Validate response structure + self._validate_vector_store_response(response) + + def _validate_vector_store_response(self, response): + """Validate the structure and content of a vector store search response""" + + # Check that response is a dictionary + assert isinstance(response, dict), f"Response should be a dict, got {type(response)}" + + # Check required top-level fields + required_fields = ['object', 'search_query', 'data'] + for field in required_fields: + assert field in response, f"Missing required field '{field}' in response" + + # Validate object field + assert response['object'] == 'vector_store.search_results.page', \ + f"Expected object to be 'vector_store.search_results.page', got '{response['object']}'" + + # Validate search_query field + assert isinstance(response['search_query'], list), \ + f"search_query should be a list, got {type(response['search_query'])}" + assert len(response['search_query']) > 0, "search_query should not be empty" + assert all(isinstance(query, str) for query in response['search_query']), \ + "All items in search_query should be strings" + + # Validate data field + assert isinstance(response['data'], list), \ + f"data should be a list, got {type(response['data'])}" + + # Validate each result in data + for i, result in enumerate(response['data']): + self._validate_search_result(result, i) + + print(f"✅ Response validation passed: Found {len(response['data'])} search results") + + def _validate_search_result(self, result, index): + """Validate an individual search result""" + + # Check that result is a dictionary + assert isinstance(result, dict), f"Result {index} should be a dict, got {type(result)}" + + # Check required fields in each result + required_result_fields = ['file_id', 'filename', 'score', 'attributes', 'content'] + for field in required_result_fields: + assert field in result, f"Missing required field '{field}' in result {index}" + + # Validate file_id + assert isinstance(result['file_id'], str), \ + f"file_id should be a string, got {type(result['file_id'])} in result {index}" + assert len(result['file_id']) > 0, f"file_id should not be empty in result {index}" + + # Validate filename + assert isinstance(result['filename'], str), \ + f"filename should be a string, got {type(result['filename'])} in result {index}" + assert len(result['filename']) > 0, f"filename should not be empty in result {index}" + + # Validate score + assert isinstance(result['score'], (int, float)), \ + f"score should be a number, got {type(result['score'])} in result {index}" + assert 0.0 <= result['score'] <= 1.0, \ + f"score should be between 0.0 and 1.0, got {result['score']} in result {index}" + + # Validate attributes + assert isinstance(result['attributes'], dict), \ + f"attributes should be a dict, got {type(result['attributes'])} in result {index}" + + # Validate content + assert isinstance(result['content'], list), \ + f"content should be a list, got {type(result['content'])} in result {index}" + assert len(result['content']) > 0, f"content should not be empty in result {index}" + + # Validate each content item + for j, content_item in enumerate(result['content']): + assert isinstance(content_item, dict), \ + f"Content item {j} in result {index} should be a dict, got {type(content_item)}" + assert 'type' in content_item, \ + f"Content item {j} in result {index} missing 'type' field" + assert 'text' in content_item, \ + f"Content item {j} in result {index} missing 'text' field" + assert isinstance(content_item['text'], str), \ + f"Content text should be a string in item {j} of result {index}" + assert len(content_item['text']) > 0, \ + f"Content text should not be empty in item {j} of result {index}" + + print(f"✅ Result {index} validation passed: {result['filename']} (score: {result['score']:.4f})") diff --git a/tests/vector_store_tests/conftest.py b/tests/vector_store_tests/conftest.py new file mode 100644 index 00000000000..b3561d8a626 --- /dev/null +++ b/tests/vector_store_tests/conftest.py @@ -0,0 +1,63 @@ +# conftest.py + +import importlib +import os +import sys + +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path +import litellm + + +@pytest.fixture(scope="function", autouse=True) +def setup_and_teardown(): + """ + This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained. + """ + curr_dir = os.getcwd() # Get the current working directory + sys.path.insert( + 0, os.path.abspath("../..") + ) # Adds the project directory to the system path + + import litellm + from litellm import Router + + importlib.reload(litellm) + + try: + if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"): + import litellm.proxy.proxy_server + + importlib.reload(litellm.proxy.proxy_server) + except Exception as e: + print(f"Error reloading litellm.proxy.proxy_server: {e}") + + import asyncio + + loop = asyncio.get_event_loop_policy().new_event_loop() + asyncio.set_event_loop(loop) + print(litellm) + # from litellm import Router, completion, aembedding, acompletion, embedding + yield + + # Teardown code (executes after the yield point) + loop.close() # Close the loop created earlier + asyncio.set_event_loop(None) # Remove the reference to the loop + + +def pytest_collection_modifyitems(config, items): + # Separate tests in 'test_amazing_proxy_custom_logger.py' and other tests + custom_logger_tests = [ + item for item in items if "custom_logger" in item.parent.name + ] + other_tests = [item for item in items if "custom_logger" not in item.parent.name] + + # Sort tests based on their names + custom_logger_tests.sort(key=lambda x: x.name) + other_tests.sort(key=lambda x: x.name) + + # Reorder the items list + items[:] = custom_logger_tests + other_tests diff --git a/tests/vector_store_tests/test_openai_vector_store.py b/tests/vector_store_tests/test_openai_vector_store.py new file mode 100644 index 00000000000..20980f2ff56 --- /dev/null +++ b/tests/vector_store_tests/test_openai_vector_store.py @@ -0,0 +1,11 @@ +from base_vector_store_test import BaseVectorStoreTest + +class TestOpenAIVectorStore(BaseVectorStoreTest): + def get_base_request_args(self) -> dict: + """ + This is a real vector store on OpenAI + """ + return { + "vector_store_id": "vs_685b14b1a1b88191bc27e04f1917fddd", + "custom_llm_provider": "openai", + } \ No newline at end of file From d6cc3847808dabbee472943f9977a1be25ab99fe Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 24 Jun 2025 20:46:48 -0700 Subject: [PATCH 39/45] [Feat] OpenAI/Azure OpenAI - Add support for creating vector stores on LiteLLM (#12021) * add create/acreate vector store * add azure config * add _base_validate_azure_environment * fix base test * add get_base_create_vector_store_args * use base llm for headers responses api * add _get_base_azure_url * fix AzureOpenAIVectorStoreConfig * TestAzureOpenAIVectorStore * fix azure openai vector store * fix test comment * fix unused imports * test_validate_environment_azure_api_key_within_secret_str * test_azure_transformation.py --- .../docs/completion/knowledgebase.md | 1 + litellm/llms/azure/common_utils.py | 80 ++++++- .../llms/azure/responses/transformation.py | 76 +------ .../azure/vector_stores/transformation.py | 27 +++ .../base_llm/vector_store/transformation.py | 13 ++ litellm/llms/custom_httpx/llm_http_handler.py | 130 +++++++++++ .../openai/vector_stores/transformation.py | 33 +++ litellm/types/vector_stores.py | 71 +++++- litellm/utils.py | 5 + litellm/vector_stores/__init__.py | 4 +- litellm/vector_stores/main.py | 202 ++++++++++++++++++ litellm/vector_stores/utils.py | 25 ++- .../response/test_azure_transformation.py | 26 ++- .../base_vector_store_test.py | 119 +++++++++++ .../test_azure_vector_store.py | 25 +++ .../test_openai_vector_store.py | 9 + 16 files changed, 764 insertions(+), 82 deletions(-) create mode 100644 litellm/llms/azure/vector_stores/transformation.py create mode 100644 tests/vector_store_tests/test_azure_vector_store.py diff --git a/docs/my-website/docs/completion/knowledgebase.md b/docs/my-website/docs/completion/knowledgebase.md index b3e6a06aa9a..a1c926274cd 100644 --- a/docs/my-website/docs/completion/knowledgebase.md +++ b/docs/my-website/docs/completion/knowledgebase.md @@ -18,6 +18,7 @@ LiteLLM integrates with vector stores, allowing your models to access your organ ## Supported Vector Stores - [Bedrock Knowledge Bases](https://aws.amazon.com/bedrock/knowledge-bases/) - [OpenAI Vector Stores](https://platform.openai.com/docs/api-reference/vector-stores/search) +- [Azure Vector Stores](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/file-search?tabs=python#vector-stores ## Quick Start diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index ed62175289c..f2a8defe13f 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -1,12 +1,11 @@ import json import os -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, Literal, Optional, Union, cast import httpx from openai import AsyncAzureOpenAI, AzureOpenAI import litellm -from litellm.types.router import GenericLiteLLMParams from litellm._logging import verbose_logger from litellm.caching.caching import DualCache from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -15,6 +14,8 @@ from litellm.secret_managers.get_azure_ad_token_provider import ( get_azure_ad_token_provider, ) from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.utils import _add_path_to_api_base azure_ad_cache = DualCache() @@ -613,3 +614,78 @@ class BaseAzureLLM(BaseOpenAILLM): else: client = AzureOpenAI(**azure_client_params) # type: ignore return client + + @staticmethod + def _base_validate_azure_environment( + headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() + api_key = ( + litellm_params.api_key + or litellm.api_key + or litellm.azure_key + or get_secret_str("AZURE_OPENAI_API_KEY") + or get_secret_str("AZURE_API_KEY") + ) + + if api_key: + headers["api-key"] = api_key + return headers + + ### Fallback to Azure AD token-based authentication if no API key is available + ### Retrieves Azure AD token and adds it to the Authorization header + azure_ad_token = get_azure_ad_token(litellm_params) + if azure_ad_token: + headers["Authorization"] = f"Bearer {azure_ad_token}" + + return headers + + @staticmethod + def _get_base_azure_url( + api_base: Optional[str], + litellm_params: Optional[Union[GenericLiteLLMParams, Dict[str, Any]]], + route: Literal["/openai/responses", "/openai/vector_stores"] + ) -> str: + api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") + if api_base is None: + raise ValueError( + f"api_base is required for Azure AI Studio. Please set the api_base parameter. Passed `api_base={api_base}`" + ) + original_url = httpx.URL(api_base) + + # Extract api_version or use default + litellm_params = litellm_params or {} + api_version = cast(Optional[str], litellm_params.get("api_version")) + + # Create a new dictionary with existing params + query_params = dict(original_url.params) + + # Add api_version if needed + if "api-version" not in query_params and api_version: + query_params["api-version"] = api_version + + # Add the path to the base URL + if route not in api_base: + new_url = _add_path_to_api_base( + api_base=api_base, ending_path=route + ) + else: + new_url = api_base + + if BaseAzureLLM._is_azure_v1_api_version(api_version): + # ensure the request go to /openai/v1 and not just /openai + if "/openai/v1" not in new_url: + parsed_url = httpx.URL(new_url) + new_url = str(parsed_url.copy_with(path=parsed_url.path.replace("/openai", "/openai/v1"))) + + + # Use the new query_params dictionary + final_url = httpx.URL(new_url).copy_with(params=query_params) + + return str(final_url) + + @staticmethod + def _is_azure_v1_api_version(api_version: Optional[str]) -> bool: + if api_version is None: + return False + return api_version == "preview" or api_version == "latest" diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index d5425ef4391..e6f48179e49 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -1,16 +1,11 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple -import httpx - -import litellm from litellm._logging import verbose_logger -from litellm.llms.azure.common_utils import get_azure_ad_token +from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig -from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import * from litellm.types.responses.main import * from litellm.types.router import GenericLiteLLMParams -from litellm.utils import _add_path_to_api_base if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -24,27 +19,11 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): 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 litellm.azure_key - or get_secret_str("AZURE_OPENAI_API_KEY") - or get_secret_str("AZURE_API_KEY") + return BaseAzureLLM._base_validate_azure_environment( + headers=headers, + litellm_params=litellm_params ) - if api_key: - headers["api-key"] = api_key - return headers - - ### Fallback to Azure AD token-based authentication if no API key is available - ### Retrieves Azure AD token and adds it to the Authorization header - azure_ad_token = get_azure_ad_token(litellm_params) - if azure_ad_token: - headers["Authorization"] = f"Bearer {azure_ad_token}" - - return headers - def get_complete_url( self, api_base: Optional[str], @@ -66,47 +45,12 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): - A complete URL string, e.g., "https://litellm8397336933.openai.azure.com/openai/responses?api-version=2024-05-01-preview" """ - api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") - if api_base is None: - raise ValueError( - f"api_base is required for Azure AI Studio. Please set the api_base parameter. Passed `api_base={api_base}`" - ) - original_url = httpx.URL(api_base) - - # Extract api_version or use default - api_version = cast(Optional[str], litellm_params.get("api_version")) - - # Create a new dictionary with existing params - query_params = dict(original_url.params) - - # Add api_version if needed - if "api-version" not in query_params and api_version: - query_params["api-version"] = api_version - - # Add the path to the base URL - if "/openai/responses" not in api_base: - new_url = _add_path_to_api_base( - api_base=api_base, ending_path="/openai/responses" - ) - else: - new_url = api_base - - if self._is_azure_v1_api_version(api_version): - # ensure the request go to /openai/v1 and not just /openai - if "/openai/v1" not in new_url: - parsed_url = httpx.URL(new_url) - new_url = str(parsed_url.copy_with(path=parsed_url.path.replace("/openai", "/openai/v1"))) - - - # Use the new query_params dictionary - final_url = httpx.URL(new_url).copy_with(params=query_params) - - return str(final_url) + return BaseAzureLLM._get_base_azure_url( + api_base=api_base, + litellm_params=litellm_params, + route="/openai/responses" + ) - def _is_azure_v1_api_version(self, api_version: Optional[str]) -> bool: - if api_version is None: - return False - return api_version == "preview" or api_version == "latest" ######################################################### ########## DELETE RESPONSE API TRANSFORMATION ############## diff --git a/litellm/llms/azure/vector_stores/transformation.py b/litellm/llms/azure/vector_stores/transformation.py new file mode 100644 index 00000000000..f1cd81b2bf2 --- /dev/null +++ b/litellm/llms/azure/vector_stores/transformation.py @@ -0,0 +1,27 @@ +from typing import Optional + +from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.openai.vector_stores.transformation import OpenAIVectorStoreConfig +from litellm.types.router import GenericLiteLLMParams + + +class AzureOpenAIVectorStoreConfig(OpenAIVectorStoreConfig): + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + return BaseAzureLLM._get_base_azure_url( + api_base=api_base, + litellm_params=litellm_params, + route="/openai/vector_stores" + ) + + + def validate_environment( + self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + return BaseAzureLLM._base_validate_azure_environment( + headers=headers, + litellm_params=litellm_params + ) \ No newline at end of file diff --git a/litellm/llms/base_llm/vector_store/transformation.py b/litellm/llms/base_llm/vector_store/transformation.py index 4df63bd2f96..6ec9d25ae59 100644 --- a/litellm/llms/base_llm/vector_store/transformation.py +++ b/litellm/llms/base_llm/vector_store/transformation.py @@ -5,6 +5,8 @@ import httpx from litellm.types.router import GenericLiteLLMParams from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateResponse, VectorStoreSearchOptionalRequestParams, VectorStoreSearchResponse, ) @@ -35,6 +37,17 @@ class BaseVectorStoreConfig: def transform_search_vector_store_response(self, response: httpx.Response) -> VectorStoreSearchResponse: pass + @abstractmethod + def transform_create_vector_store_request( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + pass + + @abstractmethod + def transform_create_vector_store_response(self, response: httpx.Response) -> VectorStoreCreateResponse: + pass @abstractmethod def validate_environment( diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index dfc5ae5277f..92f9d6d958b 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -62,6 +62,8 @@ from litellm.types.responses.main import DeleteResponseResult from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import EmbeddingResponse, FileTypes, TranscriptionResponse from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateResponse, VectorStoreSearchOptionalRequestParams, VectorStoreSearchResponse, ) @@ -2758,3 +2760,131 @@ class BaseLLMHTTPHandler: response=response, ) + async def async_vector_store_create_handler( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + vector_store_provider_config: BaseVectorStoreConfig, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + _is_async: bool = False, + ) -> VectorStoreCreateResponse: + if client is None or not isinstance(client, AsyncHTTPHandler): + async_httpx_client = get_async_httpx_client( + llm_provider=litellm.LlmProviders(custom_llm_provider), + params={"ssl_verify": litellm_params.get("ssl_verify", None)}, + ) + else: + async_httpx_client = client + + headers = vector_store_provider_config.validate_environment( + headers=extra_headers or {}, + litellm_params=litellm_params + ) + + if extra_headers: + headers.update(extra_headers) + + api_base = vector_store_provider_config.get_complete_url( + api_base=litellm_params.api_base, + litellm_params=dict(litellm_params), + ) + + url, request_body = vector_store_provider_config.transform_create_vector_store_request( + vector_store_create_optional_params=vector_store_create_optional_params, + api_base=api_base, + ) + + logging_obj.pre_call( + input="", + api_key="", + additional_args={ + "complete_input_dict": request_body, + "api_base": api_base, + "headers": headers, + }, + ) + + try: + response = await async_httpx_client.post(url=url, headers=headers, json=request_body, timeout=timeout) + except Exception as e: + raise self._handle_error(e=e, provider_config=vector_store_provider_config) + + return vector_store_provider_config.transform_create_vector_store_response( + response=response, + ) + + def vector_store_create_handler( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + vector_store_provider_config: BaseVectorStoreConfig, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + _is_async: bool = False, + ) -> Union[VectorStoreCreateResponse, Coroutine[Any, Any, VectorStoreCreateResponse]]: + if _is_async: + return self.async_vector_store_create_handler( + vector_store_create_optional_params=vector_store_create_optional_params, + vector_store_provider_config=vector_store_provider_config, + litellm_params=litellm_params, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + client=client, + ) + + if client is None or not isinstance(client, HTTPHandler): + sync_httpx_client = _get_httpx_client( + params={"ssl_verify": litellm_params.get("ssl_verify", None)} + ) + else: + sync_httpx_client = client + + headers = vector_store_provider_config.validate_environment( + headers=extra_headers or {}, + litellm_params=litellm_params + ) + + if extra_headers: + headers.update(extra_headers) + + api_base = vector_store_provider_config.get_complete_url( + api_base=litellm_params.api_base, + litellm_params=dict(litellm_params), + ) + + url, request_body = vector_store_provider_config.transform_create_vector_store_request( + vector_store_create_optional_params=vector_store_create_optional_params, + api_base=api_base, + ) + + logging_obj.pre_call( + input="", + api_key="", + additional_args={ + "complete_input_dict": request_body, + "api_base": api_base, + "headers": headers, + }, + ) + + try: + response = sync_httpx_client.post(url=url, headers=headers, json=request_body) + except Exception as e: + raise self._handle_error(e=e, provider_config=vector_store_provider_config) + + return vector_store_provider_config.transform_create_vector_store_response( + response=response, + ) + diff --git a/litellm/llms/openai/vector_stores/transformation.py b/litellm/llms/openai/vector_stores/transformation.py index 98657825684..11d76937ab4 100644 --- a/litellm/llms/openai/vector_stores/transformation.py +++ b/litellm/llms/openai/vector_stores/transformation.py @@ -7,6 +7,9 @@ from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreCon from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateRequest, + VectorStoreCreateResponse, VectorStoreSearchOptionalRequestParams, VectorStoreSearchRequest, VectorStoreSearchResponse, @@ -101,6 +104,36 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): headers=response.headers ) + def transform_create_vector_store_request( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + url = api_base # Base URL for creating vector stores + typed_request_body = VectorStoreCreateRequest( + name=vector_store_create_optional_params.get("name", None), + file_ids=vector_store_create_optional_params.get("file_ids", None), + expires_after=vector_store_create_optional_params.get("expires_after", None), + chunking_strategy=vector_store_create_optional_params.get("chunking_strategy", None), + metadata=vector_store_create_optional_params.get("metadata", None), + ) + + dict_request_body = cast(dict, typed_request_body) + return url, dict_request_body + + def transform_create_vector_store_response(self, response: httpx.Response) -> VectorStoreCreateResponse: + try: + response_json = response.json() + return VectorStoreCreateResponse( + **response_json + ) + except Exception as e: + raise self.get_error_class( + error_message=str(e), + status_code=response.status_code, + headers=response.headers + ) + diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 0fcf7a2ab00..3d7190d7f9b 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -95,4 +95,73 @@ class VectorStoreSearchOptionalRequestParams(TypedDict, total=False): class VectorStoreSearchRequest(VectorStoreSearchOptionalRequestParams, total=False): """Request body for searching a vector store""" - query: Union[str, List[str]] \ No newline at end of file + query: Union[str, List[str]] + + +# Vector Store Creation Types +class VectorStoreExpirationPolicy(TypedDict, total=False): + """The expiration policy for a vector store""" + anchor: Literal["last_active_at"] # Anchor timestamp after which the expiration policy applies + days: int # Number of days after anchor time that the vector store will expire + + +class VectorStoreAutoChunkingStrategy(TypedDict, total=False): + """Auto chunking strategy configuration""" + type: Literal["auto"] # Always "auto" + + +class VectorStoreStaticChunkingStrategyConfig(TypedDict, total=False): + """Static chunking strategy configuration""" + max_chunk_size_tokens: int # Maximum number of tokens per chunk + chunk_overlap_tokens: int # Number of tokens to overlap between chunks + + +class VectorStoreStaticChunkingStrategy(TypedDict, total=False): + """Static chunking strategy""" + type: Literal["static"] # Always "static" + static: VectorStoreStaticChunkingStrategyConfig + + +class VectorStoreChunkingStrategy(TypedDict, total=False): + """Union type for chunking strategies""" + # This can be either auto or static + type: Literal["auto", "static"] + static: Optional[VectorStoreStaticChunkingStrategyConfig] + + +class VectorStoreFileCounts(TypedDict, total=False): + """File counts for a vector store""" + in_progress: int + completed: int + failed: int + cancelled: int + total: int + + +class VectorStoreCreateOptionalRequestParams(TypedDict, total=False): + """TypedDict for Optional parameters supported by the vector store create API.""" + name: Optional[str] # Name of the vector store + file_ids: Optional[List[str]] # List of File IDs that the vector store should use + expires_after: Optional[VectorStoreExpirationPolicy] # Expiration policy for the vector store + chunking_strategy: Optional[VectorStoreChunkingStrategy] # Chunking strategy for the files + metadata: Optional[Dict[str, str]] # Set of key-value pairs for metadata + + +class VectorStoreCreateRequest(VectorStoreCreateOptionalRequestParams, total=False): + """Request body for creating a vector store""" + pass # All fields are optional for vector store creation + + +class VectorStoreCreateResponse(TypedDict, total=False): + """Response after creating a vector store""" + id: str # ID of the vector store + object: Literal["vector_store"] # Always "vector_store" + created_at: int # Unix timestamp of when the vector store was created + name: Optional[str] # Name of the vector store + bytes: int # Size of the vector store in bytes + file_counts: VectorStoreFileCounts # File counts for the vector store + status: Literal["expired", "in_progress", "completed"] # Status of the vector store + expires_after: Optional[VectorStoreExpirationPolicy] # Expiration policy + expires_at: Optional[int] # Unix timestamp of when the vector store expires + last_active_at: Optional[int] # Unix timestamp of when the vector store was last active + metadata: Optional[Dict[str, str]] # Metadata associated with the vector store \ No newline at end of file diff --git a/litellm/utils.py b/litellm/utils.py index 25f46d4bc5c..05328c3750b 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6926,6 +6926,11 @@ class ProviderConfigManager: OpenAIVectorStoreConfig, ) return OpenAIVectorStoreConfig() + elif litellm.LlmProviders.AZURE == provider: + from litellm.llms.azure.vector_stores.transformation import ( + AzureOpenAIVectorStoreConfig, + ) + return AzureOpenAIVectorStoreConfig() return None @staticmethod diff --git a/litellm/vector_stores/__init__.py b/litellm/vector_stores/__init__.py index 6546f9159e0..6bcc6540328 100644 --- a/litellm/vector_stores/__init__.py +++ b/litellm/vector_stores/__init__.py @@ -1,4 +1,4 @@ -from .main import asearch, search +from .main import acreate, asearch, create, search from .vector_store_registry import VectorStoreRegistry -__all__ = ["search", "asearch", "VectorStoreRegistry"] +__all__ = ["search", "asearch", "create", "acreate", "VectorStoreRegistry"] diff --git a/litellm/vector_stores/main.py b/litellm/vector_stores/main.py index 245a3f6fa3d..80d6341146a 100644 --- a/litellm/vector_stores/main.py +++ b/litellm/vector_stores/main.py @@ -14,6 +14,9 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.types.router import GenericLiteLLMParams from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateResponse, + VectorStoreFileCounts, VectorStoreResultContent, VectorStoreSearchOptionalRequestParams, VectorStoreSearchResponse, @@ -52,6 +55,205 @@ def mock_vector_store_search_response( ) +def mock_vector_store_create_response( + mock_response: Optional[VectorStoreCreateResponse] = None, +): + """Mock response for vector store create""" + if mock_response is None: + mock_response = VectorStoreCreateResponse( + id="vs_mock123", + object="vector_store", + created_at=1699061776, + name="Mock Vector Store", + bytes=0, + file_counts=VectorStoreFileCounts( + in_progress=0, + completed=0, + failed=0, + cancelled=0, + total=0, + ), + status="completed", + expires_after=None, + expires_at=None, + last_active_at=None, + metadata=None, + ) + + return mock_response + + +@client +async def acreate( + name: Optional[str] = None, + file_ids: Optional[List[str]] = None, + expires_after: Optional[Dict] = None, + chunking_strategy: Optional[Dict] = None, + metadata: Optional[Dict[str, str]] = None, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Optional[Dict[str, Any]] = None, + extra_query: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + # LiteLLM specific params, + custom_llm_provider: Optional[str] = None, + **kwargs, +) -> VectorStoreCreateResponse: + """ + Async: Create a vector store. + """ + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["acreate"] = True + + # get custom llm provider so we can use this for mapping exceptions + if custom_llm_provider is None: + custom_llm_provider = "openai" # Default to OpenAI for vector stores + + func = partial( + create, + name=name, + file_ids=file_ids, + expires_after=expires_after, + chunking_strategy=chunking_strategy, + metadata=metadata, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + **kwargs, + ) + + ctx = contextvars.copy_context() + func_with_context = partial(ctx.run, func) + init_response = await loop.run_in_executor(None, func_with_context) + + if asyncio.iscoroutine(init_response): + response = await init_response + else: + response = init_response + + return response + except Exception as e: + raise litellm.exception_type( + model=None, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def create( + name: Optional[str] = None, + file_ids: Optional[List[str]] = None, + expires_after: Optional[Dict] = None, + chunking_strategy: Optional[Dict] = None, + metadata: Optional[Dict[str, str]] = None, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Optional[Dict[str, Any]] = None, + extra_query: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + # LiteLLM specific params, + custom_llm_provider: Optional[str] = None, + **kwargs, +) -> Union[VectorStoreCreateResponse, Coroutine[Any, Any, VectorStoreCreateResponse]]: + """ + Create a vector store. + + Args: + name: The name of the vector store. + file_ids: A list of File IDs that the vector store should use. + expires_after: The expiration policy for the vector store. + chunking_strategy: The chunking strategy used to chunk the file(s). + metadata: Set of 16 key-value pairs that can be attached to an object. + + Returns: + VectorStoreCreateResponse containing the created vector store details. + """ + local_vars = locals() + try: + litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore + litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) + _is_async = kwargs.pop("acreate", False) is True + + # get llm provider logic + litellm_params = GenericLiteLLMParams(**kwargs) + + ## MOCK RESPONSE LOGIC + if litellm_params.mock_response and isinstance( + litellm_params.mock_response, dict + ): + return mock_vector_store_create_response( + mock_response=VectorStoreCreateResponse(**litellm_params.mock_response) + ) + + # Default to OpenAI for vector stores + if custom_llm_provider is None: + custom_llm_provider = "openai" + + # get provider config - using vector store custom logger for now + vector_store_provider_config = ProviderConfigManager.get_provider_vector_stores_config( + provider=litellm.LlmProviders(custom_llm_provider), + ) + + if vector_store_provider_config is None: + raise ValueError( + f"Vector store create is not supported for {custom_llm_provider}" + ) + + local_vars.update(kwargs) + + # Get VectorStoreCreateOptionalRequestParams with only valid parameters + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams = ( + VectorStoreRequestUtils.get_requested_vector_store_create_optional_param( + local_vars + ) + ) + + # Pre Call logging + litellm_logging_obj.update_environment_variables( + model=None, + optional_params={ + "name": name, + **vector_store_create_optional_params, + }, + litellm_params={ + "litellm_call_id": litellm_call_id, + }, + custom_llm_provider=custom_llm_provider, + ) + + response = base_llm_http_handler.vector_store_create_handler( + vector_store_create_optional_params=vector_store_create_optional_params, + vector_store_provider_config=vector_store_provider_config, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + logging_obj=litellm_logging_obj, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout or request_timeout, + _is_async=_is_async, + client=kwargs.get("client"), + ) + + return response + except Exception as e: + raise litellm.exception_type( + model=None, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + @client async def asearch( vector_store_id: str, diff --git a/litellm/vector_stores/utils.py b/litellm/vector_stores/utils.py index 7817526d00e..b7eb7790add 100644 --- a/litellm/vector_stores/utils.py +++ b/litellm/vector_stores/utils.py @@ -1,6 +1,9 @@ from typing import Any, Dict, cast, get_type_hints -from litellm.types.vector_stores import VectorStoreSearchOptionalRequestParams +from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreSearchOptionalRequestParams, +) class VectorStoreRequestUtils: @@ -26,3 +29,23 @@ class VectorStoreRequestUtils: return cast(VectorStoreSearchOptionalRequestParams, filtered_params) + @staticmethod + def get_requested_vector_store_create_optional_param( + params: Dict[str, Any], + ) -> VectorStoreCreateOptionalRequestParams: + """ + Filter parameters to only include those defined in VectorStoreCreateOptionalRequestParams. + + Args: + params: Dictionary of parameters to filter + + Returns: + VectorStoreCreateOptionalRequestParams instance with only the valid parameters + """ + valid_keys = get_type_hints(VectorStoreCreateOptionalRequestParams).keys() + filtered_params = { + k: v for k, v in params.items() if k in valid_keys and v is not None + } + + return cast(VectorStoreCreateOptionalRequestParams, filtered_params) + diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index c210f91170e..8edd87a12fc 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -54,9 +54,9 @@ def test_validate_environment_azure_key_within_litellm(): def test_validate_environment_azure_openai_api_key_within_secret_str(): azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() - with patch( - "litellm.llms.azure.responses.transformation.get_secret_str" - ) as mock_get_secret_str: + with patch("litellm.api_key", None), \ + patch("litellm.azure_key", None), \ + patch("litellm.llms.azure.common_utils.get_secret_str") as mock_get_secret_str: # Configure the mock to return "test-api-key" when called with "AZURE_OPENAI_API_KEY" mock_get_secret_str.side_effect = ( lambda key: "test-api-key" if key == "AZURE_OPENAI_API_KEY" else None @@ -74,13 +74,19 @@ def test_validate_environment_azure_openai_api_key_within_secret_str(): def test_validate_environment_azure_api_key_within_secret_str(): azure_openai_responses_apiconfig = AzureOpenAIResponsesAPIConfig() - with patch( - "litellm.llms.azure.responses.transformation.get_secret_str" - ) as mock_get_secret_str: - # Configure the mock to return "test-api-key" when called with "AZURE_API_KEY" - mock_get_secret_str.side_effect = ( - lambda key: "test-api-key" if key == "AZURE_API_KEY" else None - ) + with patch("litellm.api_key", None), \ + patch("litellm.azure_key", None), \ + patch("litellm.llms.azure.common_utils.get_secret_str") as mock_get_secret_str: + # Configure the mock to return None for "AZURE_OPENAI_API_KEY" and "test-api-key" for "AZURE_API_KEY" + def mock_side_effect(key): + if key == "AZURE_OPENAI_API_KEY": + return None + elif key == "AZURE_API_KEY": + return "test-api-key" + else: + return None + + mock_get_secret_str.side_effect = mock_side_effect litellm_params = GenericLiteLLMParams() result = azure_openai_responses_apiconfig.validate_environment( diff --git a/tests/vector_store_tests/base_vector_store_test.py b/tests/vector_store_tests/base_vector_store_test.py index d23012847bb..ac1ec704f36 100644 --- a/tests/vector_store_tests/base_vector_store_test.py +++ b/tests/vector_store_tests/base_vector_store_test.py @@ -26,6 +26,11 @@ class BaseVectorStoreTest(ABC): def get_base_request_args(self) -> dict: """Must return the base request args""" pass + + @abstractmethod + def get_base_create_vector_store_args(self) -> dict: + """Must return the base create vector store args""" + pass @pytest.mark.parametrize("sync_mode", [True, False]) @pytest.mark.asyncio @@ -52,6 +57,39 @@ class BaseVectorStoreTest(ABC): # Validate response structure self._validate_vector_store_response(response) + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_basic_create_vector_store(self, sync_mode): + litellm._turn_on_debug() + litellm.set_verbose = True + base_request_args = self.get_base_create_vector_store_args() + + # Extract custom_llm_provider from base args if present + create_args = base_request_args + try: + if sync_mode: + response = litellm.vector_stores.create( + name="Test Vector Store", + **create_args + ) + else: + response = await litellm.vector_stores.acreate( + name="Test Vector Store", + **create_args + ) + except litellm.InternalServerError: + pytest.skip("Skipping test due to litellm.InternalServerError") + except Exception as e: + # If this is an authentication or permission error, skip the test + if "authentication" in str(e).lower() or "permission" in str(e).lower() or "unauthorized" in str(e).lower(): + pytest.skip(f"Skipping test due to authentication/permission error: {e}") + raise + + print("litellm create response=", json.dumps(response, indent=4, default=str)) + + # Validate response structure + self._validate_vector_store_create_response(response) + def _validate_vector_store_response(self, response): """Validate the structure and content of a vector store search response""" @@ -84,6 +122,87 @@ class BaseVectorStoreTest(ABC): print(f"✅ Response validation passed: Found {len(response['data'])} search results") + def _validate_vector_store_create_response(self, response): + """Validate the structure and content of a vector store create response""" + + # Check that response is a dictionary + assert isinstance(response, dict), f"Response should be a dict, got {type(response)}" + + # Check required top-level fields for create response + required_fields = ['id', 'object', 'created_at'] + for field in required_fields: + assert field in response, f"Missing required field '{field}' in create response" + + # Validate object field + assert response['object'] == 'vector_store', \ + f"Expected object to be 'vector_store', got '{response['object']}'" + + # Validate id field + assert isinstance(response['id'], str), \ + f"id should be a string, got {type(response['id'])}" + assert len(response['id']) > 0, "id should not be empty" + assert response['id'].startswith('vs_'), \ + f"id should start with 'vs_', got '{response['id']}'" + + # Validate created_at field + assert isinstance(response['created_at'], int), \ + f"created_at should be an integer, got {type(response['created_at'])}" + assert response['created_at'] > 0, "created_at should be a positive timestamp" + + # Validate optional fields if present + if 'name' in response: + assert isinstance(response['name'], str), \ + f"name should be a string, got {type(response['name'])}" + + if 'bytes' in response: + assert isinstance(response['bytes'], int), \ + f"bytes should be an integer, got {type(response['bytes'])}" + assert response['bytes'] >= 0, "bytes should be non-negative" + + if 'file_counts' in response: + self._validate_file_counts(response['file_counts']) + + if 'status' in response: + valid_statuses = ['expired', 'in_progress', 'completed'] + assert response['status'] in valid_statuses, \ + f"status should be one of {valid_statuses}, got '{response['status']}'" + + if 'expires_at' in response and response['expires_at'] is not None: + assert isinstance(response['expires_at'], int), \ + f"expires_at should be an integer, got {type(response['expires_at'])}" + + if 'last_active_at' in response and response['last_active_at'] is not None: + assert isinstance(response['last_active_at'], int), \ + f"last_active_at should be an integer, got {type(response['last_active_at'])}" + + if 'metadata' in response and response['metadata'] is not None: + assert isinstance(response['metadata'], dict), \ + f"metadata should be a dict, got {type(response['metadata'])}" + + print(f"✅ Create response validation passed: Vector store '{response['id']}' created successfully") + + def _validate_file_counts(self, file_counts): + """Validate file_counts structure""" + assert isinstance(file_counts, dict), \ + f"file_counts should be a dict, got {type(file_counts)}" + + required_count_fields = ['in_progress', 'completed', 'failed', 'cancelled', 'total'] + for field in required_count_fields: + assert field in file_counts, f"Missing required field '{field}' in file_counts" + assert isinstance(file_counts[field], int), \ + f"{field} should be an integer, got {type(file_counts[field])}" + assert file_counts[field] >= 0, f"{field} should be non-negative" + + # Validate that total equals sum of other counts + calculated_total = ( + file_counts['in_progress'] + + file_counts['completed'] + + file_counts['failed'] + + file_counts['cancelled'] + ) + assert file_counts['total'] == calculated_total, \ + f"total should equal sum of other counts ({calculated_total}), got {file_counts['total']}" + def _validate_search_result(self, result, index): """Validate an individual search result""" diff --git a/tests/vector_store_tests/test_azure_vector_store.py b/tests/vector_store_tests/test_azure_vector_store.py new file mode 100644 index 00000000000..783417cb742 --- /dev/null +++ b/tests/vector_store_tests/test_azure_vector_store.py @@ -0,0 +1,25 @@ +from base_vector_store_test import BaseVectorStoreTest +import os +import pytest + +class TestAzureOpenAIVectorStore(BaseVectorStoreTest): + def get_base_request_args(self) -> dict: + """Must return the base request args""" + return {} + + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_basic_search_vector_store(self, sync_mode): + pass + + + def get_base_create_vector_store_args(self) -> dict: + """ + This is a real vector store on Azure + """ + return { + "custom_llm_provider": "azure", + "api_base": os.getenv("AZURE_RESPONSES_OPENAI_ENDPOINT"), + "api_key": os.getenv("AZURE_RESPONSES_OPENAI_API_KEY"), + "api_version": "2025-04-01-preview", + } \ No newline at end of file diff --git a/tests/vector_store_tests/test_openai_vector_store.py b/tests/vector_store_tests/test_openai_vector_store.py index 20980f2ff56..3e27be2f64a 100644 --- a/tests/vector_store_tests/test_openai_vector_store.py +++ b/tests/vector_store_tests/test_openai_vector_store.py @@ -8,4 +8,13 @@ class TestOpenAIVectorStore(BaseVectorStoreTest): return { "vector_store_id": "vs_685b14b1a1b88191bc27e04f1917fddd", "custom_llm_provider": "openai", + } + + + def get_base_create_vector_store_args(self) -> dict: + """ + This is a real vector store on OpenAI + """ + return { + "custom_llm_provider": "openai", } \ No newline at end of file From 15dabf6573f1076cb9d39f265cac0a22b938994b Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:48:08 -0600 Subject: [PATCH 40/45] docs(CLAUDE.md): add development guidance and architecture overview for Claude Code (#12011) --- CLAUDE.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..50bed6e43e2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Installation +- `make install-dev` - Install core development dependencies +- `make install-proxy-dev` - Install proxy development dependencies with full feature set +- `make install-test-deps` - Install all test dependencies + +### Testing +- `make test` - Run all tests +- `make test-unit` - Run unit tests (tests/test_litellm) with 4 parallel workers +- `make test-integration` - Run integration tests (excludes unit tests) +- `pytest tests/` - Direct pytest execution + +### Code Quality +- `make lint` - Run all linting (Ruff, MyPy, Black, circular imports, import safety) +- `make format` - Apply Black code formatting +- `make lint-ruff` - Run Ruff linting only +- `make lint-mypy` - Run MyPy type checking only + +### Single Test Files +- `poetry run pytest tests/path/to/test_file.py -v` - Run specific test file +- `poetry run pytest tests/path/to/test_file.py::test_function -v` - Run specific test + +## Architecture Overview + +LiteLLM is a unified interface for 100+ LLM providers with two main components: + +### Core Library (`litellm/`) +- **Main entry point**: `litellm/main.py` - Contains core completion() function +- **Provider implementations**: `litellm/llms/` - Each provider has its own subdirectory +- **Router system**: `litellm/router.py` + `litellm/router_utils/` - Load balancing and fallback logic +- **Type definitions**: `litellm/types/` - Pydantic models and type hints +- **Integrations**: `litellm/integrations/` - Third-party observability, caching, logging +- **Caching**: `litellm/caching/` - Multiple cache backends (Redis, in-memory, S3, etc.) + +### Proxy Server (`litellm/proxy/`) +- **Main server**: `proxy_server.py` - FastAPI application +- **Authentication**: `auth/` - API key management, JWT, OAuth2 +- **Database**: `db/` - Prisma ORM with PostgreSQL/SQLite support +- **Management endpoints**: `management_endpoints/` - Admin APIs for keys, teams, models +- **Pass-through endpoints**: `pass_through_endpoints/` - Provider-specific API forwarding +- **Guardrails**: `guardrails/` - Safety and content filtering hooks +- **UI Dashboard**: Served from `_experimental/out/` (Next.js build) + +## Key Patterns + +### Provider Implementation +- Providers inherit from base classes in `litellm/llms/base.py` +- Each provider has transformation functions for input/output formatting +- Support both sync and async operations +- Handle streaming responses and function calling + +### Error Handling +- Provider-specific exceptions mapped to OpenAI-compatible errors +- Fallback logic handled by Router system +- Comprehensive logging through `litellm/_logging.py` + +### Configuration +- YAML config files for proxy server (see `proxy/example_config_yaml/`) +- Environment variables for API keys and settings +- Database schema managed via Prisma (`proxy/schema.prisma`) + +## Development Notes + +### Code Style +- Uses Black formatter, Ruff linter, MyPy type checker +- Pydantic v2 for data validation +- Async/await patterns throughout +- Type hints required for all public APIs + +### Testing Strategy +- Unit tests in `tests/test_litellm/` +- Integration tests for each provider in `tests/llm_translation/` +- Proxy tests in `tests/proxy_unit_tests/` +- Load tests in `tests/load_tests/` + +### Database Migrations +- Prisma handles schema migrations +- Migration files auto-generated with `prisma migrate dev` +- Always test migrations against both PostgreSQL and SQLite + +### Enterprise Features +- Enterprise-specific code in `enterprise/` directory +- Optional features enabled via environment variables +- Separate licensing and authentication for enterprise features \ No newline at end of file From ac15ca3014027159e352b153d5d5fc5a9a3f55c0 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 24 Jun 2025 21:58:07 -0700 Subject: [PATCH 41/45] Teams - Support default key expiry + UI - support enforcing access for members of specific SSO Group (#12023) * fix(team_endpoints.py): support setting default key expiry allows admin to set key expiry on all team member keys makes it easier to setup default team for experimentation * feat(key_management_endpoints.py): allows admin to set duration for keys created by team members * feat(team_endpoints.py): support team_member_key_duration on `/team/update` allows setting max time team member keys are valid for * fix(team_info.tsx): ui component to update team member key duration * fix(team_info.tsx): support updating team member key duration, if set * feat(teams.tsx): add team member key duration param ui component allow admin to set this on UI * feat(ui_sso.py): support restricting ui access by sso group allows controlling who can/can't access the UI * feat(ssomodals.tsx): add initial commit adding sso group access to admin ui * feat(proxy_server.py): support reading + writing ui_access_mode from db allows admin to configure allowed sso groups from UI * feat(ui_sso.py): support enforcing all teams on sso jwt handler if ui access mode set via ui, support reading the value and enforcing it * feat(ui/): ui component for controlling sso access group allow admin to only allow users within specific sso group to log into UI * fix(uiaccesscontrolform.tsx): fix field names * feat(ui_sso.py): return received sso response in the clientside error message - enables easier debugging * test: add unit tests * fix: minor fixes --- .../key_management_endpoints.py | 30 ++ litellm/proxy/_new_secret_config.yaml | 2 +- litellm/proxy/_types.py | 3 + litellm/proxy/auth/handle_jwt.py | 2 + litellm/proxy/litellm.log | 357 ++++++++++++++++++ .../key_management_endpoints.py | 74 ++-- .../management_endpoints/sso_helper_utils.py | 9 +- .../management_endpoints/team_endpoints.py | 2 + litellm/proxy/management_endpoints/ui_sso.py | 121 +++++- litellm/proxy/proxy_server.py | 8 +- .../proxy_setting_endpoints.py | 87 +++-- .../proxy/management_endpoints/ui_sso.py | 22 +- .../proxy/management_endpoints/test_ui_sso.py | 6 +- .../src/components/SSOModals.tsx | 2 +- .../src/components/UIAccessControlForm.tsx | 148 ++++++++ .../src/components/admins.tsx | 36 ++ .../src/components/team/team_info.tsx | 29 +- ui/litellm-dashboard/src/components/teams.tsx | 11 + 18 files changed, 864 insertions(+), 85 deletions(-) create mode 100644 enterprise/litellm_enterprise/proxy/management_endpoints/key_management_endpoints.py create mode 100644 litellm/proxy/litellm.log create mode 100644 ui/litellm-dashboard/src/components/UIAccessControlForm.tsx diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/key_management_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/key_management_endpoints.py new file mode 100644 index 00000000000..19ce8090db7 --- /dev/null +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/key_management_endpoints.py @@ -0,0 +1,30 @@ +from typing import Optional + +from litellm.proxy._types import GenerateKeyRequest, LiteLLM_TeamTable + + +def add_team_member_key_duration( + team_table: Optional[LiteLLM_TeamTable], + data: GenerateKeyRequest, +) -> GenerateKeyRequest: + if team_table is None: + return data + + if data.user_id is None: # only apply for team member keys, not service accounts + return data + + if ( + team_table.metadata is not None + and team_table.metadata.get("team_member_key_duration") is not None + ): + data.duration = team_table.metadata["team_member_key_duration"] + + return data + + +def apply_enterprise_key_management_params( + data: GenerateKeyRequest, + team_table: Optional[LiteLLM_TeamTable], +) -> GenerateKeyRequest: + data = add_team_member_key_duration(team_table, data) + return data diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 07e0a96f55d..40243ae668b 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -1,4 +1,4 @@ model_list: - model_name: gemini-2.5-pro litellm_params: - model: gemini/gemini-2.5-pro \ No newline at end of file + model: gemini/gemini-2.5-pro diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7d358cad030..111ef89f7df 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1115,6 +1115,7 @@ class NewTeamRequest(TeamBase): team_member_budget: Optional[float] = ( None # allow user to set a budget for all team members ) + team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m" model_config = ConfigDict(protected_namespaces=()) @@ -1157,6 +1158,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): guardrails: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None team_member_budget: Optional[float] = None + team_member_key_duration: Optional[str] = None class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase): @@ -2792,6 +2794,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [ LiteLLM_ManagementEndpoint_MetadataFields_Premium = [ "guardrails", "tags", + "team_member_key_duration", ] diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index fa91785e6b7..046f66173d2 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -164,7 +164,9 @@ class JWTHandler: self.litellm_jwtauth.team_ids_jwt_field is not None and token.get(self.litellm_jwtauth.team_ids_jwt_field) is not None ): + return token[self.litellm_jwtauth.team_ids_jwt_field] + return [] def get_end_user_id( diff --git a/litellm/proxy/litellm.log b/litellm/proxy/litellm.log new file mode 100644 index 00000000000..4f592f5cc0b --- /dev/null +++ b/litellm/proxy/litellm.log @@ -0,0 +1,357 @@ +18:10:09 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle +18:10:11 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist! +18:10:11 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:10:11 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:10:23 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback +18:10:27 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback +18:10:27 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback +18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[] +18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:671 - user_defined_values for creating ui key: {'models': [], 'user_id': 'krrishd', 'user_email': 'krrishdholakia@gmail.com', 'max_budget': None, 'user_role': 'proxy_admin', 'budget_duration': None} +18:10:28 - LiteLLM Proxy:INFO: utils.py:1856 - Data Inserted into Keys Table +18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:761 - user_id: krrishd; jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoia3JyaXNoZCIsImtleSI6InNrLTVvOXVVc0ZaaTVBRFBiWERoanhCZlEiLCJ1c2VyX2VtYWlsIjoia3JyaXNoZGhvbGFraWFAZ21haWwuY29tIiwidXNlcl9yb2xlIjoicHJveHlfYWRtaW4iLCJsb2dpbl9tZXRob2QiOiJzc28iLCJwcmVtaXVtX3VzZXIiOnRydWUsImF1dGhfaGVhZGVyX25hbWUiOiJBdXRob3JpemF0aW9uIiwiZGlzYWJsZWRfbm9uX2FkbWluX3BlcnNvbmFsX2tleV9jcmVhdGlvbiI6ZmFsc2UsInNlcnZlcl9yb290X3BhdGgiOiIvIn0.OiZdFjZ2wiMhFbMCwu2cZYXh7oV5BB8Vta-Ysk5JBQU +18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:764 - Redirecting to http://localhost:4000/ui/?login=success +18:10:30 - LiteLLM Proxy:ERROR: key_management_endpoints.py:2275 - Error in list_keys: Server disconnected without sending a response. +Traceback (most recent call last): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions + yield + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request + resp = await self._pool.handle_async_request(req) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request + raise exc from None + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request + response = await connection.handle_async_request( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 101, in handle_async_request + return await self._connection.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 143, in handle_async_request + raise exc + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 113, in handle_async_request + ) = await self._receive_response_headers(**kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 186, in _receive_response_headers + event = await self._receive_event(timeout=timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 238, in _receive_event + raise RemoteProtocolError(msg) +httpcore.RemoteProtocolError: Server disconnected without sending a response. + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/management_endpoints/key_management_endpoints.py", line 2255, in list_keys + response = await _list_key_helper( + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/management_endpoints/key_management_endpoints.py", line 2434, in _list_key_helper + total_count = await prisma_client.db.litellm_verificationtoken.count( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 10157, in count + resp = await self._client._execute( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute + return await self._engine.query(builder.build(), tx_id=self._tx_id) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query + return await self.request( + ^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request + response = await self.session.request(method, url, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request + return Response(await self.session.request(method, url, **kwargs)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request + return await self.send(request, auth=auth, follow_redirects=follow_redirects) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send + response = await self._send_handling_auth( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth + response = await self._send_handling_redirects( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects + response = await self._send_single_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request + response = await transport.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request + with map_httpcore_exceptions(): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions + raise mapped_exc(message) from exc +httpx.RemoteProtocolError: Server disconnected without sending a response. +18:10:30 - LiteLLM Proxy:ERROR: proxy_server.py:2730 - litellm.proxy_server.py::add_deployment() - Error getting new models from DB - All connection attempts failed +Traceback (most recent call last): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions + yield + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request + resp = await self._pool.handle_async_request(req) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request + raise exc from None + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request + response = await connection.handle_async_request( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request + raise exc + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request + stream = await self._connect(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 122, in _connect + stream = await self._network_backend.connect_tcp(**kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp + return await self._backend.connect_tcp( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp + with map_exceptions(exc_map): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions + raise to_exc(exc) from exc +httpcore.ConnectError: All connection attempts failed + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2728, in _get_models_from_db + new_models = await prisma_client.db.litellm_proxymodeltable.find_many() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 2540, in find_many + resp = await self._client._execute( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute + return await self._engine.query(builder.build(), tx_id=self._tx_id) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query + return await self.request( + ^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request + response = await self.session.request(method, url, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request + return Response(await self.session.request(method, url, **kwargs)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request + return await self.send(request, auth=auth, follow_redirects=follow_redirects) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send + response = await self._send_handling_auth( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth + response = await self._send_handling_redirects( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects + response = await self._send_single_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request + response = await transport.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request + with map_httpcore_exceptions(): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions + raise mapped_exc(message) from exc +httpx.ConnectError: All connection attempts failed +18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed +18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed +18:10:30 - LiteLLM Proxy:ERROR: proxy_server.py:2778 - litellm.proxy.proxy_server.py::ProxyConfig:add_deployment - All connection attempts failed +Traceback (most recent call last): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions + yield + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request + resp = await self._pool.handle_async_request(req) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request + raise exc from None + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request + response = await connection.handle_async_request( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request + raise exc + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request + stream = await self._connect(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 122, in _connect + stream = await self._network_backend.connect_tcp(**kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp + return await self._backend.connect_tcp( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp + with map_exceptions(exc_map): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions + raise to_exc(exc) from exc +httpcore.ConnectError: All connection attempts failed + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2760, in add_deployment + await self._update_llm_router( + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2418, in _update_llm_router + config_data = await proxy_config.get_config() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 1584, in get_config + config = await self._update_config_from_db( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2706, in _update_config_from_db + responses = await asyncio.gather(*_tasks) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/db/log_db_metrics.py", line 99, in wrapper + raise e + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/db/log_db_metrics.py", line 42, in wrapper + result = await func(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/backoff/_async.py", line 151, in retry + ret = await target(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/utils.py", line 1418, in get_generic_data + raise e + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/utils.py", line 1392, in get_generic_data + response = await self.db.litellm_config.find_first( # type: ignore + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 11822, in find_first + resp = await self._client._execute( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute + return await self._engine.query(builder.build(), tx_id=self._tx_id) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query + return await self.request( + ^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request + response = await self.session.request(method, url, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request + return Response(await self.session.request(method, url, **kwargs)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request + return await self.send(request, auth=auth, follow_redirects=follow_redirects) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send + response = await self._send_handling_auth( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth + response = await self._send_handling_redirects( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects + response = await self._send_single_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request + response = await transport.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request + with map_httpcore_exceptions(): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions + raise mapped_exc(message) from exc +httpx.ConnectError: All connection attempts failed +18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed +18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed +18:10:30 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server +18:11:47 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle +18:11:49 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist! +18:11:50 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:11:50 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:12:00 - LiteLLM Proxy:ERROR: proxy_server.py:2925 - litellm.proxy_server.py::get_credentials() - Error getting credentials from DB - Server disconnected without sending a response. +Traceback (most recent call last): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions + yield + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request + resp = await self._pool.handle_async_request(req) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request + raise exc from None + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request + response = await connection.handle_async_request( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 101, in handle_async_request + return await self._connection.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 143, in handle_async_request + raise exc + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 113, in handle_async_request + ) = await self._receive_response_headers(**kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 186, in _receive_response_headers + event = await self._receive_event(timeout=timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 238, in _receive_event + raise RemoteProtocolError(msg) +httpcore.RemoteProtocolError: Server disconnected without sending a response. + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2916, in get_credentials + credentials = await prisma_client.db.litellm_credentialstable.find_many() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 1502, in find_many + resp = await self._client._execute( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute + return await self._engine.query(builder.build(), tx_id=self._tx_id) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query + return await self.request( + ^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request + response = await self.session.request(method, url, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request + return Response(await self.session.request(method, url, **kwargs)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request + return await self.send(request, auth=auth, follow_redirects=follow_redirects) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send + response = await self._send_handling_auth( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth + response = await self._send_handling_redirects( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects + response = await self._send_single_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request + response = await transport.handle_async_request(request) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request + with map_httpcore_exceptions(): + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__ + self.gen.throw(typ, value, traceback) + File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions + raise mapped_exc(message) from exc +httpx.RemoteProtocolError: Server disconnected without sending a response. +18:12:01 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server +18:12:14 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle +18:12:16 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist! +18:12:16 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:12:16 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:12:21 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback +18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback +18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback +18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[] +18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:672 - user_defined_values for creating ui key: {'models': [], 'user_id': 'krrishd', 'user_email': 'krrishdholakia@gmail.com', 'max_budget': None, 'user_role': 'proxy_admin', 'budget_duration': None} +18:12:27 - LiteLLM Proxy:INFO: utils.py:1856 - Data Inserted into Keys Table +18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:762 - user_id: krrishd; jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoia3JyaXNoZCIsImtleSI6InNrLUQzMEFpdW9lckU3YlMyakFXWVFLd1EiLCJ1c2VyX2VtYWlsIjoia3JyaXNoZGhvbGFraWFAZ21haWwuY29tIiwidXNlcl9yb2xlIjoicHJveHlfYWRtaW4iLCJsb2dpbl9tZXRob2QiOiJzc28iLCJwcmVtaXVtX3VzZXIiOnRydWUsImF1dGhfaGVhZGVyX25hbWUiOiJBdXRob3JpemF0aW9uIiwiZGlzYWJsZWRfbm9uX2FkbWluX3BlcnNvbmFsX2tleV9jcmVhdGlvbiI6ZmFsc2UsInNlcnZlcl9yb290X3BhdGgiOiIvIn0.EzYP86hw12J4WHLe6ZZz4YgVNGPnxM_PHqLjINH2_-U +18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:765 - Redirecting to http://localhost:4000/ui/?login=success +18:12:31 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server +18:15:07 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle +18:15:09 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist! +18:15:09 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:15:09 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments. +18:15:17 - LiteLLM Proxy:INFO: utils.py:1916 - Data Inserted into Config Table +18:15:28 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback +18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback +18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback +18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[] +18:15:37 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index fdc8e73dad3..0b1c7f523c0 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -512,6 +512,20 @@ async def generate_key_fn( # noqa: PLR0915 }, ) + # APPLY ENTERPRISE KEY MANAGEMENT PARAMS + try: + from litellm_enterprise.proxy.management_endpoints.key_management_endpoints import ( + apply_enterprise_key_management_params, + ) + + data = apply_enterprise_key_management_params(data, team_table) + except Exception as e: + verbose_proxy_logger.info( + "litellm.proxy.proxy_server.generate_key_fn(): Enterprise key management params not applied - {}".format( + str(e) + ) + ) + # TODO: @ishaan-jaff: Migrate all budget tracking to use LiteLLM_BudgetTable _budget_id = data.budget_id if prisma_client is not None and data.soft_budget is not None: @@ -536,7 +550,7 @@ async def generate_key_fn( # noqa: PLR0915 # ADD METADATA FIELDS # Set Management Endpoint Metadata Fields for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium: - if getattr(data, field) is not None: + if getattr(data, field, None) is not None: _set_object_metadata_field( object_data=data, field_name=field, @@ -589,9 +603,9 @@ async def generate_key_fn( # noqa: PLR0915 request_type="key", **data_json, table_name="key" ) - response[ - "soft_budget" - ] = data.soft_budget # include the user-input soft budget in the response + response["soft_budget"] = ( + data.soft_budget + ) # include the user-input soft budget in the response response = GenerateKeyResponse(**response) @@ -667,9 +681,9 @@ async def _set_object_permission( data=data_json["object_permission"], ) ) - data_json[ - "object_permission_id" - ] = created_object_permission.object_permission_id + data_json["object_permission_id"] = ( + created_object_permission.object_permission_id + ) # delete the object_permission from the data_json data_json.pop("object_permission") @@ -1652,10 +1666,10 @@ async def delete_verification_tokens( try: if prisma_client: tokens = [_hash_token_if_needed(token=key) for key in tokens] - _keys_being_deleted: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={"token": {"in": tokens}} + _keys_being_deleted: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={"token": {"in": tokens}} + ) ) if len(_keys_being_deleted) == 0: @@ -1763,9 +1777,9 @@ async def _rotate_master_key( from litellm.proxy.proxy_server import proxy_config try: - models: Optional[ - List - ] = await prisma_client.db.litellm_proxymodeltable.find_many() + models: Optional[List] = ( + await prisma_client.db.litellm_proxymodeltable.find_many() + ) except Exception: models = None # 2. process model table @@ -2057,11 +2071,11 @@ async def validate_key_list_check( param="user_id", code=status.HTTP_403_FORBIDDEN, ) - complete_user_info_db_obj: Optional[ - BaseModel - ] = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_api_key_dict.user_id}, - include={"organization_memberships": True}, + complete_user_info_db_obj: Optional[BaseModel] = ( + await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_api_key_dict.user_id}, + include={"organization_memberships": True}, + ) ) if complete_user_info_db_obj is None: @@ -2147,10 +2161,10 @@ async def get_admin_team_ids( if complete_user_info is None: return [] # Get all teams that user is an admin of - teams: Optional[ - List[BaseModel] - ] = await prisma_client.db.litellm_teamtable.find_many( - where={"team_id": {"in": complete_user_info.teams}} + teams: Optional[List[BaseModel]] = ( + await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"in": complete_user_info.teams}} + ) ) if teams is None: return [] @@ -2403,12 +2417,14 @@ async def _list_key_helper( where=where, # type: ignore skip=skip, # type: ignore take=size, # type: ignore - order=order_by - if order_by - else [ - {"created_at": "desc"}, - {"token": "desc"}, # fallback sort - ], + order=( + order_by + if order_by + else [ + {"created_at": "desc"}, + {"token": "desc"}, # fallback sort + ] + ), include={"object_permission": True}, ) diff --git a/litellm/proxy/management_endpoints/sso_helper_utils.py b/litellm/proxy/management_endpoints/sso_helper_utils.py index 45906b2fce0..7b296a6646f 100644 --- a/litellm/proxy/management_endpoints/sso_helper_utils.py +++ b/litellm/proxy/management_endpoints/sso_helper_utils.py @@ -1,9 +1,14 @@ +from typing import Dict, Union + from litellm.proxy._types import LitellmUserRoles -def check_is_admin_only_access(ui_access_mode: str) -> bool: +def check_is_admin_only_access(ui_access_mode: Union[str, Dict]) -> bool: """Checks ui access mode is admin_only""" - return ui_access_mode == "admin_only" + if isinstance(ui_access_mode, str): + return ui_access_mode == "admin_only" + else: + return False def has_admin_ui_access(user_role: str) -> bool: diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 31d2877ea69..3d4ae4e945f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -262,6 +262,7 @@ async def new_team( # noqa: PLR0915 - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission. - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. + - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo" Returns: - team_id: (str) Unique team id - used for tracking spend across multiple keys for same team id. @@ -688,6 +689,7 @@ async def update_team( - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission. - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. + - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo" Example - update team TPM Limit ``` diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 29a078ec06c..77fdb64b0e9 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -145,7 +145,11 @@ async def google_login(request: Request): # noqa: PLR0915 return HTMLResponse(content=html_form, status_code=200) -def generic_response_convertor(response, jwt_handler: JWTHandler): +def generic_response_convertor( + response, + jwt_handler: JWTHandler, + sso_jwt_handler: Optional[JWTHandler] = None, +): generic_user_id_attribute_name = os.getenv( "GENERIC_USER_ID_ATTRIBUTE", "preferred_username" ) @@ -171,6 +175,13 @@ def generic_response_convertor(response, jwt_handler: JWTHandler): f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}" ) + all_teams = [] + if sso_jwt_handler is not None: + team_ids = sso_jwt_handler.get_team_ids_from_jwt(cast(dict, response)) + all_teams.extend(team_ids) + + team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response)) + all_teams.extend(team_ids) return CustomOpenID( id=response.get(generic_user_id_attribute_name), display_name=response.get(generic_user_display_name_attribute_name), @@ -178,20 +189,24 @@ def generic_response_convertor(response, jwt_handler: JWTHandler): first_name=response.get(generic_user_first_name_attribute_name), last_name=response.get(generic_user_last_name_attribute_name), provider=response.get(generic_provider_attribute_name), - team_ids=jwt_handler.get_team_ids_from_jwt(cast(dict, response)), + team_ids=all_teams, ) async def get_generic_sso_response( request: Request, jwt_handler: JWTHandler, + sso_jwt_handler: Optional[ + JWTHandler + ], # sso specific jwt handler - used for restricted sso group access control generic_client_id: str, redirect_url: str, -) -> Union[OpenID, dict]: +) -> Tuple[Union[OpenID, dict], Optional[dict]]: # return received response # make generic sso provider from fastapi_sso.sso.base import DiscoveryDocument from fastapi_sso.sso.generic import create_provider + received_response: Optional[dict] = None generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None) generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split(" ") generic_authorization_endpoint = os.getenv("GENERIC_AUTHORIZATION_ENDPOINT", None) @@ -242,9 +257,12 @@ async def get_generic_sso_response( ) def response_convertor(response, client): + nonlocal received_response # return for user debugging + received_response = response return generic_response_convertor( response=response, jwt_handler=jwt_handler, + sso_jwt_handler=sso_jwt_handler, ) SSOProvider = create_provider( @@ -284,7 +302,7 @@ async def get_generic_sso_response( ) raise e verbose_proxy_logger.debug("generic result: %s", result) - return result or {} + return result or {}, received_response async def create_team_member_add_task(team_id, user_info): @@ -480,6 +498,8 @@ async def check_and_update_if_proxy_admin_id( async def auth_callback(request: Request): # noqa: PLR0915 """Verify login""" verbose_proxy_logger.info("Starting SSO callback") + from litellm.proxy._types import LiteLLM_JWTAuth + from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, ) @@ -490,7 +510,6 @@ async def auth_callback(request: Request): # noqa: PLR0915 premium_user, prisma_client, proxy_logging_obj, - ui_access_mode, user_api_key_cache, user_custom_sso, ) @@ -502,9 +521,25 @@ async def auth_callback(request: Request): # noqa: PLR0915 status_code=500, detail=CommonProxyErrors.db_not_connected_error.value ) + sso_jwt_handler: Optional[JWTHandler] = None + ui_access_mode = general_settings.get("ui_access_mode", None) + if ui_access_mode is not None and isinstance(ui_access_mode, dict): + sso_jwt_handler = JWTHandler() + sso_jwt_handler.update_environment( + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + litellm_jwtauth=LiteLLM_JWTAuth( + team_ids_jwt_field=general_settings.get("ui_access_mode", {}).get( + "sso_group_jwt_field", None + ), + ), + leeway=0, + ) + microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None) google_client_id = os.getenv("GOOGLE_CLIENT_ID", None) generic_client_id = os.getenv("GENERIC_CLIENT_ID", None) + received_response: Optional[dict] = None # get url from request if master_key is None: raise ProxyException( @@ -532,11 +567,12 @@ async def auth_callback(request: Request): # noqa: PLR0915 redirect_url=redirect_url, ) elif generic_client_id is not None: - result = await get_generic_sso_response( + result, received_response = await get_generic_sso_response( request=request, jwt_handler=jwt_handler, generic_client_id=generic_client_id, redirect_url=redirect_url, + sso_jwt_handler=sso_jwt_handler, ) if result is None: @@ -547,6 +583,7 @@ async def auth_callback(request: Request): # noqa: PLR0915 # User is Authe'd in - generate key for the UI to access Proxy verbose_proxy_logger.info(f"SSO callback result: {result}") + user_email: Optional[str] = getattr(result, "email", None) user_id: Optional[str] = getattr(result, "id", None) if result is not None else None @@ -612,6 +649,13 @@ async def auth_callback(request: Request): # noqa: PLR0915 budget_duration=internal_user_budget_duration, ) + # (IF SET) Verify user is in restricted SSO group + SSOAuthenticationHandler.verify_user_in_restricted_sso_group( + general_settings=general_settings, + result=result, + received_response=received_response, + ) + user_info = await get_user_info_from_db( result=result, prisma_client=prisma_client, @@ -1055,6 +1099,44 @@ class SSOAuthenticationHandler: sso_teams = getattr(result, "team_ids", []) await add_missing_team_member(user_info=user_info, sso_teams=sso_teams) + @staticmethod + def verify_user_in_restricted_sso_group( + general_settings: Dict, + result: Optional[Union[CustomOpenID, OpenID, dict]], + received_response: Optional[dict], + ) -> Literal[True]: + """ + when ui_access_mode.type == "restricted_sso_group": + + - result.team_ids should contain the restricted_sso_group + - if not, raise a ProxyException + - if so, return True + - if result.team_ids is None, return False + - if result.team_ids is an empty list, return False + - if result.team_ids is a list, return True if the restricted_sso_group is in the list, otherwise return False + """ + + ui_access_mode = cast( + Optional[Union[Dict, str]], general_settings.get("ui_access_mode") + ) + + if ui_access_mode is None: + return True + if isinstance(ui_access_mode, str): + return True + team_ids = getattr(result, "team_ids", []) + + if ui_access_mode.get("type") == "restricted_sso_group": + restricted_sso_group = ui_access_mode.get("restricted_sso_group") + if restricted_sso_group not in team_ids: + raise ProxyException( + message=f"User is not in the restricted SSO group: {restricted_sso_group}. User groups: {team_ids}. Received SSO response: {received_response}", + type=ProxyErrorTypes.auth_error, + param="restricted_sso_group", + code=status.HTTP_403_FORBIDDEN, + ) + return True + @staticmethod async def create_litellm_team_from_sso_group( litellm_team_id: str, @@ -1551,7 +1633,29 @@ async def debug_sso_callback(request: Request): from fastapi.responses import HTMLResponse - from litellm.proxy.proxy_server import jwt_handler + from litellm.proxy._types import LiteLLM_JWTAuth + from litellm.proxy.auth.handle_jwt import JWTHandler + from litellm.proxy.proxy_server import ( + general_settings, + jwt_handler, + prisma_client, + user_api_key_cache, + ) + + sso_jwt_handler: Optional[JWTHandler] = None + ui_access_mode = general_settings.get("ui_access_mode", None) + if ui_access_mode is not None and isinstance(ui_access_mode, dict): + sso_jwt_handler = JWTHandler() + sso_jwt_handler.update_environment( + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + litellm_jwtauth=LiteLLM_JWTAuth( + team_ids_jwt_field=general_settings.get("ui_access_mode", {}).get( + "sso_group_jwt_field", None + ), + ), + leeway=0, + ) microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None) google_client_id = os.getenv("GOOGLE_CLIENT_ID", None) @@ -1580,11 +1684,12 @@ async def debug_sso_callback(request: Request): ) elif generic_client_id is not None: - result = await get_generic_sso_response( + result, _ = await get_generic_sso_response( request=request, jwt_handler=jwt_handler, generic_client_id=generic_client_id, redirect_url=redirect_url, + sso_jwt_handler=sso_jwt_handler, ) # If result is None, return a basic error message diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 95173d1c092..0a8abdd19ec 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -905,7 +905,7 @@ health_check_results: Dict[str, Union[int, List[Dict[str, Any]]]] = {} queue: List = [] litellm_proxy_budget_name = "litellm-proxy-budget" litellm_proxy_admin_name = LITELLM_PROXY_ADMIN_NAME -ui_access_mode: Literal["admin", "all"] = "all" +ui_access_mode: Union[Literal["admin", "all"], Dict] = "all" proxy_budget_rescheduler_min_time = PROXY_BUDGET_RESCHEDULER_MIN_TIME proxy_budget_rescheduler_max_time = PROXY_BUDGET_RESCHEDULER_MAX_TIME proxy_batch_write_at = PROXY_BATCH_WRITE_AT @@ -1435,11 +1435,13 @@ class ProxyConfig: - Do not write restricted params like 'api_key' to the database - if api_key is passed, save that to the local environment or connected secret manage (maybe expose `litellm.save_secret()`) """ + if prisma_client is not None and ( general_settings.get("store_model_in_db", False) is True or store_model_in_db ): # if using - db for config - models are in ModelTable + new_config.pop("model_list", None) await prisma_client.insert_data(data=new_config, table_name="config") else: @@ -2625,6 +2627,10 @@ class ProxyConfig: pass_through_endpoints=general_settings["pass_through_endpoints"] ) + ## UI ACCESS MODE ## + if "ui_access_mode" in _general_settings: + general_settings["ui_access_mode"] = _general_settings["ui_access_mode"] + def _update_config_fields( self, current_config: dict, diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 25991862fa1..884afd02bc4 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -7,7 +7,10 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams, SSOConfig +from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + SSOConfig, +) router = APIRouter() @@ -18,26 +21,29 @@ class IPAddress(BaseModel): class SettingsResponse(BaseModel): """Base response model for settings with values and schema information""" - + values: Dict[str, Any] """The current configuration values""" - + field_schema: Dict[str, Any] """Schema information including descriptions and property types for UI display""" class SSOSettingsResponse(SettingsResponse): """Response model for SSO settings""" + pass class InternalUserSettingsResponse(SettingsResponse): """Response model for internal user settings""" + pass class DefaultTeamSettingsResponse(SettingsResponse): """Response model for default team settings""" + pass @@ -166,7 +172,10 @@ async def _get_settings_with_schema( # Add descriptions to the response result = { "values": settings_dict, - "field_schema": {"description": schema.get("description", ""), "properties": {}}, + "field_schema": { + "description": schema.get("description", ""), + "properties": {}, + }, } # Add property descriptions @@ -322,20 +331,21 @@ async def get_sso_settings(): Returns a structured object with values and descriptions for UI display. """ import os + from litellm.proxy.proxy_server import proxy_config - + # Load existing config to get both environment variables and general settings config = await proxy_config.get_config() general_settings = config.get("general_settings", {}) or {} environment_variables = config.get("environment_variables", {}) or {} - + # Get user_email from general_settings proxy_admin_email = general_settings.get("proxy_admin_email", None) - + # Helper function to get env var value (first from config, then from environment) def get_env_value(env_var_name: str): return environment_variables.get(env_var_name) or os.getenv(env_var_name) - + # Get current environment variables for SSO sso_config = SSOConfig( google_client_id=get_env_value("GOOGLE_CLIENT_ID"), @@ -351,27 +361,31 @@ async def get_sso_settings(): proxy_base_url=get_env_value("PROXY_BASE_URL"), user_email=proxy_admin_email, # Get from config instead of environment ) - + # Get the schema for UI display from pydantic import TypeAdapter + schema = TypeAdapter(SSOConfig).json_schema(by_alias=True) - + # Convert to dict for response sso_dict = sso_config.model_dump() - + # Add descriptions to the response result = { "values": sso_dict, - "field_schema": {"description": schema.get("description", ""), "properties": {}}, + "field_schema": { + "description": schema.get("description", ""), + "properties": {}, + }, } - + # Add property descriptions for field_name, field_info in schema["properties"].items(): result["field_schema"]["properties"][field_name] = { "description": field_info.get("description", ""), "type": field_info.get("type", "string"), } - + return result @@ -384,51 +398,56 @@ async def update_sso_settings(sso_config: SSOConfig): """ Update SSO configuration by saving to both environment variables and config file. """ - from litellm.proxy.proxy_server import proxy_config import os - + + from litellm.proxy.proxy_server import proxy_config + # Update environment variables env_var_mapping = { - 'google_client_id': 'GOOGLE_CLIENT_ID', - 'google_client_secret': 'GOOGLE_CLIENT_SECRET', - 'microsoft_client_id': 'MICROSOFT_CLIENT_ID', - 'microsoft_client_secret': 'MICROSOFT_CLIENT_SECRET', - 'microsoft_tenant': 'MICROSOFT_TENANT', - 'generic_client_id': 'GENERIC_CLIENT_ID', - 'generic_client_secret': 'GENERIC_CLIENT_SECRET', - 'generic_authorization_endpoint': 'GENERIC_AUTHORIZATION_ENDPOINT', - 'generic_token_endpoint': 'GENERIC_TOKEN_ENDPOINT', - 'generic_userinfo_endpoint': 'GENERIC_USERINFO_ENDPOINT', - 'proxy_base_url': 'PROXY_BASE_URL', + "google_client_id": "GOOGLE_CLIENT_ID", + "google_client_secret": "GOOGLE_CLIENT_SECRET", + "microsoft_client_id": "MICROSOFT_CLIENT_ID", + "microsoft_client_secret": "MICROSOFT_CLIENT_SECRET", + "microsoft_tenant": "MICROSOFT_TENANT", + "generic_client_id": "GENERIC_CLIENT_ID", + "generic_client_secret": "GENERIC_CLIENT_SECRET", + "generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT", + "generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT", + "generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT", + "proxy_base_url": "PROXY_BASE_URL", } - + # Load existing config config = await proxy_config.get_config() - + # Update config with new environment variables if "environment_variables" not in config: config["environment_variables"] = {} - + # Update general_settings for user_email (admin email) if "general_settings" not in config: config["general_settings"] = {} - + # Update environment variables in config and in memory sso_data = sso_config.model_dump(exclude_none=True) for field_name, value in sso_data.items(): - if field_name == 'user_email' and value is not None: + + if field_name == "user_email" and value is not None: # Store user_email in general_settings instead of environment variables config["general_settings"]["proxy_admin_email"] = value + elif field_name == "ui_access_mode" and value is not None: + + config["general_settings"]["ui_access_mode"] = value elif field_name in env_var_mapping and value is not None: env_var_name = env_var_mapping[field_name] # Update in config config["environment_variables"][env_var_name] = value # Update in runtime environment os.environ[env_var_name] = value - + # Save the updated config await proxy_config.save_config(new_config=config) - + return { "message": "SSO settings updated successfully", "status": "success", diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index f6838b61703..3cdb5cb6398 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, TypedDict +from typing import List, Literal, Optional, TypedDict, Union from pydantic import Field @@ -31,6 +31,14 @@ class MicrosoftServicePrincipalTeam(TypedDict, total=False): principalId: Optional[str] +class AccessControl_UI_AccessMode(LiteLLMPydanticObjectBase): + """Model for Controlling UI Access Mode via SSO Groups""" + + type: Literal["restricted_sso_group"] + restricted_sso_group: str + sso_group_jwt_field: str + + class SSOConfig(LiteLLMPydanticObjectBase): """ Configuration for SSO environment variables and settings @@ -45,7 +53,7 @@ class SSOConfig(LiteLLMPydanticObjectBase): default=None, description="Google OAuth Client Secret for SSO authentication", ) - + # Microsoft SSO microsoft_client_id: Optional[str] = Field( default=None, @@ -59,7 +67,7 @@ class SSOConfig(LiteLLMPydanticObjectBase): default=None, description="Microsoft Azure Tenant ID for SSO authentication", ) - + # Generic/Okta SSO generic_client_id: Optional[str] = Field( default=None, @@ -81,7 +89,7 @@ class SSOConfig(LiteLLMPydanticObjectBase): default=None, description="User info endpoint URL for generic OAuth provider", ) - + # Common settings proxy_base_url: Optional[str] = Field( default=None, @@ -92,6 +100,12 @@ class SSOConfig(LiteLLMPydanticObjectBase): description="Email of the proxy admin user", ) + # Access Mode + ui_access_mode: Optional[Union[AccessControl_UI_AccessMode, str]] = Field( + default=None, + description="Access mode for the UI", + ) + class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 2ce4cf29380..60199b335a5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -696,11 +696,12 @@ async def test_get_generic_sso_response_with_additional_headers(): "fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class ) as mock_create_provider: # Act - result = await get_generic_sso_response( + result, received_response = await get_generic_sso_response( request=mock_request, jwt_handler=mock_jwt_handler, generic_client_id=generic_client_id, redirect_url=redirect_url, + sso_jwt_handler=None, ) # Assert @@ -756,11 +757,12 @@ async def test_get_generic_sso_response_with_empty_headers(): "fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class ) as mock_create_provider: # Act - result = await get_generic_sso_response( + result, received_response = await get_generic_sso_response( request=mock_request, jwt_handler=mock_jwt_handler, generic_client_id=generic_client_id, redirect_url=redirect_url, + sso_jwt_handler=None, ) # Assert diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index 0779edb89d2..763242aeb63 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -132,7 +132,7 @@ const SSOModals: React.FC = ({ } } - // Set form values with existing data + // Set form values with existing data (excluding UI access control fields) const formValues = { sso_provider: selectedProvider, proxy_base_url: ssoData.values.proxy_base_url, diff --git a/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx new file mode 100644 index 00000000000..def9bf6bb91 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; +import { Form, Button as Button2, Select, message } from "antd"; +import { Text, TextInput } from "@tremor/react"; +import { getSSOSettings, updateSSOSettings } from "./networking"; + +interface UIAccessControlFormProps { + accessToken: string | null; + onSuccess: () => void; +} + +// Separate UI Access Control Form Component +const UIAccessControlForm: React.FC = ({ accessToken, onSuccess }) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + // Load existing UI access control settings + useEffect(() => { + const loadUIAccessSettings = async () => { + if (accessToken) { + try { + const ssoData = await getSSOSettings(accessToken); + if (ssoData && ssoData.values) { + // Handle nested ui_access_mode structure + const uiAccessMode = ssoData.values.ui_access_mode; + let formValues = {}; + + if (uiAccessMode && typeof uiAccessMode === 'object') { + formValues = { + ui_access_mode_type: uiAccessMode.type, + restricted_sso_group: uiAccessMode.restricted_sso_group, + sso_group_jwt_field: uiAccessMode.sso_group_jwt_field, + }; + } else if (typeof uiAccessMode === 'string') { + // Handle legacy flat structure + formValues = { + ui_access_mode_type: uiAccessMode, + restricted_sso_group: ssoData.values.restricted_sso_group, + sso_group_jwt_field: ssoData.values.team_ids_jwt_field || ssoData.values.sso_group_jwt_field, + }; + } + + form.setFieldsValue(formValues); + } + } catch (error) { + console.error("Failed to load UI access settings:", error); + } + } + }; + + loadUIAccessSettings(); + }, [accessToken, form]); + + const handleUIAccessSubmit = async (formValues: Record) => { + if (!accessToken) { + message.error("No access token available"); + return; + } + + setLoading(true); + try { + // Transform form data to match API expected structure + const apiPayload = { + ui_access_mode: { + type: formValues.ui_access_mode_type, + restricted_sso_group: formValues.restricted_sso_group, + sso_group_jwt_field: formValues.sso_group_jwt_field, + } + }; + + await updateSSOSettings(accessToken, apiPayload); + onSuccess(); + } catch (error) { + console.error("Failed to save UI access settings:", error); + message.error("Failed to save UI access settings"); + } finally { + setLoading(false); + } + }; + + return ( +

+ ); +}; + +export default UIAccessControlForm; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index 6b3ddc094d5..a876aa9d519 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -44,6 +44,7 @@ import { InvitationLink } from "./onboarding_link"; import SSOModals from "./SSOModals"; import { ssoProviderConfigs } from './SSOModals'; import SCIMConfig from "./SCIM"; +import UIAccessControlForm from "./UIAccessControlForm"; interface AdminPanelProps { searchParams: any; @@ -97,6 +98,7 @@ const AdminPanel: React.FC = ({ const [isAllowedIPModalVisible, setIsAllowedIPModalVisible] = useState(false); const [isAddIPModalVisible, setIsAddIPModalVisible] = useState(false); const [isDeleteIPModalVisible, setIsDeleteIPModalVisible] = useState(false); + const [isUIAccessControlModalVisible, setIsUIAccessControlModalVisible] = useState(false); const [allowedIPs, setAllowedIPs] = useState([]); const [ipToDelete, setIPToDelete] = useState(null); const [ssoConfigured, setSsoConfigured] = useState(false); @@ -532,6 +534,14 @@ const AdminPanel: React.FC = ({ } }; + const handleUIAccessControlOk = () => { + setIsUIAccessControlModalVisible(false); + }; + + const handleUIAccessControlCancel = () => { + setIsUIAccessControlModalVisible(false); + }; + console.log(`admins: ${admins?.length}`); return (
@@ -563,6 +573,14 @@ const AdminPanel: React.FC = ({ Allowed IPs
+
+ +
@@ -654,6 +672,24 @@ const AdminPanel: React.FC = ({ >

Are you sure you want to delete the IP address: {ipToDelete}?

+ + {/* UI Access Control Modal */} + + { + handleUIAccessControlOk(); + message.success("UI Access Control settings updated successfully"); + }} + /> + If you need to login without sso, you can access{" "} diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index 0d79f56262d..439e7f4a6d0 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -260,6 +260,10 @@ const TeamInfoView: React.FC = ({ updateData.team_member_budget = Number(values.team_member_budget); } + if (values.team_member_key_duration !== undefined) { + updateData.team_member_key_duration = values.team_member_key_duration; + } + // Handle object_permission updates if (values.vector_stores !== undefined || values.mcp_servers !== undefined) { updateData.object_permission = { @@ -453,6 +457,15 @@ const TeamInfoView: React.FC = ({ + + + + +