From 573fd83731976f8f166be518eacb958a1405600b Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 2 Feb 2026 13:44:44 +0530 Subject: [PATCH] =?UTF-8?q?Revert=20"fix:=20emit=20first=20chunk=20text=20?= =?UTF-8?q?delta=20for=20anthropic=20in=20responses=20API=20strea=E2=80=A6?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9dc55ea69afc60b5aa5708794a2eb218e0e03076. --- .../providers/azure_ai/azure_model_router.md | 163 ++---- .../claude_code_plugin_marketplace.md | 4 +- litellm/cost_calculator.py | 65 +-- litellm/litellm_core_utils/litellm_logging.py | 6 - .../azure_ai/azure_model_router/__init__.py | 4 - .../azure_model_router/transformation.py | 125 ----- litellm/llms/azure_ai/common_utils.py | 78 +-- litellm/llms/azure_ai/cost_calculator.py | 102 ---- litellm/llms/base_llm/chat/transformation.py | 20 - ...odel_prices_and_context_window_backup.json | 8 - litellm/proxy/cached_logo.jpg | Bin 24694 -> 50535 bytes litellm/proxy/common_request_processing.py | 18 +- litellm/proxy/proxy_server.py | 2 +- .../streaming_iterator.py | 7 +- litellm/types/utils.py | 1 - litellm/utils.py | 5 +- model_prices_and_context_window.json | 8 - tests/llm_translation/test_azure_ai.py | 28 +- .../test_claude_agent_sdk.py | 4 +- .../llms/azure_ai/test_cost_calculator.py | 345 ------------ ...ations_anthropic_claude3_transformation.py | 18 +- .../test_litellm_completion_responses.py | 80 +-- .../Navbar/UserDropdown/UserDropdown.test.tsx | 289 ---------- .../Navbar/UserDropdown/UserDropdown.tsx | 161 ------ .../src/components/mcp_tools/mcp_connect.tsx | 28 +- .../src/components/navbar.test.tsx | 39 +- .../src/components/navbar.tsx | 183 ++++++- .../view_logs/CostBreakdownViewer.tsx | 50 +- .../GuardrailViewer/GuardrailViewer.test.tsx | 24 +- .../GuardrailViewer/GuardrailViewer.tsx | 92 ++-- .../LogDetailsDrawer/CollapsibleMessage.tsx | 88 --- .../LogDetailsDrawer/DrawerHeader.tsx | 205 ------- .../LogDetailsDrawer/HistorySection.tsx | 62 --- .../LogDetailsDrawer/HistoryTree.tsx | 83 --- .../view_logs/LogDetailsDrawer/InputCard.tsx | 92 ---- .../view_logs/LogDetailsDrawer/JsonViewer.tsx | 35 -- .../LogDetailsDrawer/LogDetailsDrawer.tsx | 507 ------------------ .../LogDetailsDrawer/MessageBlock.tsx | 104 ---- .../LogDetailsDrawer/MessageCard.tsx | 199 ------- .../view_logs/LogDetailsDrawer/OutputCard.tsx | 104 ---- .../LogDetailsDrawer/PrettyMessagesView.tsx | 41 -- .../LogDetailsDrawer/SectionHeader.tsx | 100 ---- .../LogDetailsDrawer/SimpleMessageBlock.tsx | 74 --- .../LogDetailsDrawer/SimpleToolCallBlock.tsx | 65 --- .../view_logs/LogDetailsDrawer/TokenFlow.tsx | 22 - .../LogDetailsDrawer/ToolCallBlock.tsx | 78 --- .../LogDetailsDrawer/ToolCallCard.tsx | 79 --- .../LogDetailsDrawer/TruncatedValue.tsx | 35 -- .../view_logs/LogDetailsDrawer/constants.ts | 42 -- .../view_logs/LogDetailsDrawer/index.ts | 2 - .../LogDetailsDrawer/prettyMessagesTypes.ts | 28 - .../LogDetailsDrawer/prettyMessagesUtils.ts | 126 ----- .../LogDetailsDrawer/useKeyboardNavigation.ts | 87 --- .../view_logs/LogDetailsDrawer/utils.ts | 93 ---- .../ToolsSection/FormattedToolView.tsx | 124 ----- .../view_logs/ToolsSection/JsonToolView.tsx | 39 -- .../ToolsSection/ToolExpandedContent.tsx | 52 -- .../view_logs/ToolsSection/ToolItem.tsx | 74 --- .../ToolsSection/ToolsSection.test.tsx | 117 ---- .../view_logs/ToolsSection/ToolsSection.tsx | 65 --- .../view_logs/ToolsSection/index.ts | 7 - .../view_logs/ToolsSection/types.ts | 42 -- .../view_logs/ToolsSection/utils.test.ts | 293 ---------- .../view_logs/ToolsSection/utils.ts | 130 ----- .../view_logs/VectorStoreViewer.tsx | 38 +- .../src/components/view_logs/columns.tsx | 40 ++ .../src/components/view_logs/index.tsx | 46 +- .../src/components/view_logs/table.tsx | 24 +- ui/litellm-dashboard/tsconfig.tsbuildinfo | 2 +- 69 files changed, 430 insertions(+), 4971 deletions(-) delete mode 100644 litellm/llms/azure_ai/azure_model_router/__init__.py delete mode 100644 litellm/llms/azure_ai/azure_model_router/transformation.py delete mode 100644 litellm/llms/azure_ai/cost_calculator.py delete mode 100644 tests/test_litellm/llms/azure_ai/test_cost_calculator.py delete mode 100644 ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/CollapsibleMessage.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/HistorySection.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/HistoryTree.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/MessageBlock.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/MessageCard.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ToolCallBlock.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ToolCallCard.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesTypes.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolExpandedContent.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/index.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/types.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.test.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.ts diff --git a/docs/my-website/docs/providers/azure_ai/azure_model_router.md b/docs/my-website/docs/providers/azure_ai/azure_model_router.md index 16bc1afb70e..5e14c7283f6 100644 --- a/docs/my-website/docs/providers/azure_ai/azure_model_router.md +++ b/docs/my-website/docs/providers/azure_ai/azure_model_router.md @@ -5,38 +5,19 @@ Azure Model Router is a feature in Azure AI Foundry that automatically routes yo ## Key Features - **Automatic Model Selection**: Azure Model Router dynamically selects the best model for your request -- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), plus the Model Router infrastructure fee +- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), not the router endpoint - **Streaming Support**: Full support for streaming responses with accurate cost calculation -- **Simple Configuration**: Easy to set up via UI or config file - -## Model Naming Pattern - -Use the pattern: `azure_ai/model_router/` - -**Components:** -- `azure_ai` - The provider identifier -- `model_router` - Indicates this is a Model Router deployment -- `` - Your actual deployment name from Azure AI Foundry (e.g., `azure-model-router`) - -**Example:** `azure_ai/model_router/azure-model-router` - -**How it works:** -- LiteLLM automatically strips the `model_router/` prefix when sending requests to Azure -- Only your deployment name (e.g., `azure-model-router`) is sent to the Azure API -- The full path is preserved in responses and logs for proper cost tracking ## LiteLLM Python SDK ### Basic Usage -Use the pattern `azure_ai/model_router/` where `` is your Azure deployment name: - ```python import litellm import os response = litellm.completion( - model="azure_ai/model_router/azure-model-router", # Use your deployment name + model="azure_ai/azure-model-router", messages=[{"role": "user", "content": "Hello!"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -45,13 +26,6 @@ response = litellm.completion( print(response) ``` -**Pattern Explanation:** -- `azure_ai` - The provider -- `model_router` - Indicates this is a model router deployment -- `azure-model-router` - Your actual deployment name from Azure AI Foundry - -LiteLLM will automatically strip the `model_router/` prefix when sending the request to Azure, so only `azure-model-router` is sent to the API. - ### Streaming with Usage Tracking ```python @@ -59,7 +33,7 @@ import litellm import os response = await litellm.acompletion( - model="azure_ai/model_router/azure-model-router", # Use your deployment name + model="azure_ai/azure-model-router", messages=[{"role": "user", "content": "hi"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -77,15 +51,13 @@ async for chunk in response: ```yaml model_list: - - model_name: azure-model-router # Public name for your users + - model_name: azure-model-router litellm_params: - model: azure_ai/model_router/azure-model-router # Use your deployment name + model: azure_ai/azure-model-router api_base: https://your-endpoint.cognitiveservices.azure.com/openai/v1/ api_key: os.environ/AZURE_MODEL_ROUTER_API_KEY ``` -**Note:** Replace `azure-model-router` in the model path with your actual deployment name from Azure AI Foundry. - ### Start Proxy ```bash @@ -108,42 +80,49 @@ curl -X POST http://localhost:4000/chat/completions \ This walkthrough shows how to add an Azure Model Router endpoint to LiteLLM using the Admin Dashboard. -### Quick Start - -1. Navigate to the **Models** page in the LiteLLM UI -2. Select **"Azure AI Foundry (Studio)"** as the provider -3. Enter your deployment name (e.g., `azure-model-router`) -4. LiteLLM will automatically format it as `azure_ai/model_router/azure-model-router` -5. Add your API base URL and API key -6. Test and save - -### Detailed Walkthrough - -#### Step 1: Select Provider +### Select Provider Navigate to the Models page and select "Azure AI Foundry (Studio)" as the provider. -##### Navigate to Models Page +#### Navigate to Models Page ![Navigate to Models](./img/azure_model_router_01.jpeg) -##### Click Provider Dropdown +#### Click Provider Dropdown ![Click Provider](./img/azure_model_router_02.jpeg) -##### Choose Azure AI Foundry +#### Choose Azure AI Foundry ![Select Azure AI Foundry](./img/azure_model_router_03.jpeg) -#### Step 2: Enter Deployment Name +### Configure Model Name -**New Simplified Method:** Just enter your deployment name directly in the text field. If your deployment name contains "model-router" or "model_router", LiteLLM will automatically format it as `azure_ai/model_router/`. +Set up the model name by entering `azure_ai/` followed by your model router deployment name from Azure. -**Example:** -- Enter: `azure-model-router` -- LiteLLM creates: `azure_ai/model_router/azure-model-router` +#### Click Model Name Field -##### Copy Deployment Name from Azure Portal +![Click Model Field](./img/azure_model_router_04.jpeg) + +#### Select Custom Model Name + +![Select Custom Model](./img/azure_model_router_05.jpeg) + +#### Enter LiteLLM Model Name + +![LiteLLM Model Name](./img/azure_model_router_06.jpeg) + +#### Click Custom Model Name Field + +![Enter Custom Name Field](./img/azure_model_router_07.jpeg) + +#### Type Model Prefix + +Type `azure_ai/` as the prefix. + +![Type azure_ai prefix](./img/azure_model_router_08.jpeg) + +#### Copy Model Name from Azure Portal Switch to Azure AI Foundry and copy your model router deployment name. @@ -151,79 +130,73 @@ Switch to Azure AI Foundry and copy your model router deployment name. ![Copy Model Name](./img/azure_model_router_10.jpeg) -##### Enter Deployment Name in LiteLLM +#### Paste Model Name -Paste your deployment name (e.g., `azure-model-router`) directly into the text field. +Paste to get `azure_ai/azure-model-router`. -![Enter Deployment Name](./img/azure_model_router_04.jpeg) +![Paste Model Name](./img/azure_model_router_11.jpeg) -**What happens behind the scenes:** -- You enter: `azure-model-router` -- LiteLLM automatically detects this is a model router deployment -- The full model path becomes: `azure_ai/model_router/azure-model-router` -- When making API calls, only `azure-model-router` is sent to Azure - -#### Step 3: Configure API Base and Key +### Configure API Base and Key Copy the endpoint URL and API key from Azure portal. -##### Copy API Base URL from Azure +#### Copy API Base URL from Azure ![Copy API Base](./img/azure_model_router_12.jpeg) -##### Enter API Base in LiteLLM +#### Enter API Base in LiteLLM ![Click API Base Field](./img/azure_model_router_13.jpeg) ![Paste API Base](./img/azure_model_router_14.jpeg) -##### Copy API Key from Azure +#### Copy API Key from Azure ![Copy API Key](./img/azure_model_router_15.jpeg) -##### Enter API Key in LiteLLM +#### Enter API Key in LiteLLM ![Enter API Key](./img/azure_model_router_16.jpeg) -#### Step 4: Test and Add Model +### Test and Add Model Verify your configuration works and save the model. -##### Test Connection +#### Test Connection ![Test Connection](./img/azure_model_router_17.jpeg) -##### Close Test Dialog +#### Close Test Dialog ![Close Dialog](./img/azure_model_router_18.jpeg) -##### Add Model +#### Add Model ![Add Model](./img/azure_model_router_19.jpeg) -#### Step 5: Verify in Playground +### Verify in Playground Test your model and verify cost tracking is working. -##### Open Playground +#### Open Playground ![Go to Playground](./img/azure_model_router_20.jpeg) -##### Select Model +#### Select Model ![Select Model](./img/azure_model_router_21.jpeg) -##### Send Test Message +#### Send Test Message ![Send Message](./img/azure_model_router_22.jpeg) -##### View Logs +#### View Logs ![View Logs](./img/azure_model_router_23.jpeg) -##### Verify Cost Tracking +#### Verify Cost Tracking -Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`), plus a flat infrastructure cost of $0.14 per million input tokens for using the Model Router. +Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`). ![Verify Cost](./img/azure_model_router_24.jpeg) @@ -232,50 +205,28 @@ Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`), plus a fl LiteLLM automatically handles cost tracking for Azure Model Router by: 1. **Detecting the actual model**: When Azure Model Router routes your request to a specific model (e.g., `gpt-4.1-nano-2025-04-14`), LiteLLM extracts this from the response -2. **Calculating accurate costs**: Costs are calculated based on: - - The actual model used (e.g., `gpt-4.1-nano` token costs) - - Plus a flat infrastructure cost of **$0.14 per million input tokens** for using the Model Router +2. **Calculating accurate costs**: Costs are calculated based on the actual model used, not the router endpoint name 3. **Streaming support**: Cost tracking works correctly for both streaming and non-streaming requests -### Cost Breakdown - -When you use Azure Model Router, the total cost includes: - -- **Model Cost**: Based on the actual model that handled your request (e.g., `gpt-4.1-nano`) -- **Router Flat Cost**: $0.14 per million input tokens (Azure AI Foundry infrastructure fee) - ### Example Response with Cost ```python import litellm response = litellm.completion( - model="azure_ai/model_router/azure-model-router", + model="azure_ai/azure-model-router", messages=[{"role": "user", "content": "Hello!"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key="your-api-key", ) # The response will show the actual model used -print(f"Model used: {response.model}") # e.g., "azure_ai/gpt-4.1-nano-2025-04-14" +print(f"Model used: {response.model}") # e.g., "gpt-4.1-nano-2025-04-14" -# Get cost (includes both model cost and router flat cost) +# Get cost from litellm import completion_cost cost = completion_cost(completion_response=response) -print(f"Total cost: ${cost}") - -# Access detailed cost breakdown -if hasattr(response, '_hidden_params') and 'response_cost' in response._hidden_params: - print(f"Response cost: ${response._hidden_params['response_cost']}") +print(f"Cost: ${cost}") ``` -### Viewing Cost Breakdown in UI - -When viewing logs in the LiteLLM UI, you'll see: -- **Model Cost**: The cost for the actual model used -- **Azure Model Router Flat Cost**: The $0.14/M input tokens infrastructure fee -- **Total Cost**: Sum of both costs - -This breakdown helps you understand exactly what you're paying for when using the Model Router. - diff --git a/docs/my-website/docs/tutorials/claude_code_plugin_marketplace.md b/docs/my-website/docs/tutorials/claude_code_plugin_marketplace.md index 9d93c717c4f..946fb47d92a 100644 --- a/docs/my-website/docs/tutorials/claude_code_plugin_marketplace.md +++ b/docs/my-website/docs/tutorials/claude_code_plugin_marketplace.md @@ -2,7 +2,7 @@ import Image from '@theme/IdealImage'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Claude Code Plugin Marketplace (Managed Skills) +# Claude Code Plugin Marketplace LiteLLM AI Gateway acts as a central registry for Claude Code plugins. Admins can govern which plugins are available across the organization, and engineers can discover and install approved plugins from a single source. @@ -252,7 +252,7 @@ curl -X POST http://localhost:4000/claude-code/plugins \ }' ``` -### 3. Use in Claude Code +### 3. Share with Your Team Send engineers the marketplace URL: diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index bef4d52ce49..490f0288b00 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -36,9 +36,6 @@ from litellm.llms.anthropic.cost_calculation import ( from litellm.llms.azure.cost_calculation import ( cost_per_token as azure_openai_cost_per_token, ) -from litellm.llms.azure_ai.cost_calculator import ( - cost_per_token as azure_ai_cost_per_token, -) from litellm.llms.base_llm.search.transformation import SearchResponse from litellm.llms.bedrock.cost_calculation import ( cost_per_token as bedrock_cost_per_token, @@ -141,51 +138,6 @@ def _cost_per_token_custom_pricing_helper( return None -def _get_additional_costs( - model: str, - custom_llm_provider: Optional[str], - prompt_tokens: int, - completion_tokens: int, -) -> Optional[dict]: - """ - Calculate additional costs beyond standard token costs. - - This function delegates to provider-specific config classes to calculate - any additional costs like routing fees, infrastructure costs, etc. - - Args: - model: The model name - custom_llm_provider: The provider name (optional) - prompt_tokens: Number of prompt tokens - completion_tokens: Number of completion tokens - - Returns: - Optional dictionary with cost names and amounts, or None if no additional costs - """ - if not custom_llm_provider: - return None - - try: - config_class = None - if custom_llm_provider == "azure_ai": - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo - config_class = AzureFoundryModelInfo.get_azure_ai_config_for_model(model) - # Add more providers here as needed - # elif custom_llm_provider == "other_provider": - # config_class = get_other_provider_config(model) - - if config_class and hasattr(config_class, 'calculate_additional_costs'): - return config_class.calculate_additional_costs( - model=model, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - except Exception as e: - verbose_logger.debug(f"Error calculating additional costs: {e}") - - return None - - def _transcription_usage_has_token_details( usage_block: Optional[Usage], ) -> bool: @@ -475,8 +427,8 @@ def cost_per_token( # noqa: PLR0915 return dashscope_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "azure_ai": - return azure_ai_cost_per_token( - model=model, usage=usage_block, response_time_ms=response_time_ms + return generic_cost_per_token( + model=model, usage=usage_block, custom_llm_provider=custom_llm_provider ) else: model_info = _cached_get_model_info_helper( @@ -853,7 +805,6 @@ def _store_cost_breakdown_in_logging_obj( completion_tokens_cost_usd_dollar: float, cost_for_built_in_tools_cost_usd_dollar: float, total_cost_usd_dollar: float, - additional_costs: Optional[dict] = None, original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, @@ -870,7 +821,6 @@ def _store_cost_breakdown_in_logging_obj( completion_tokens_cost_usd_dollar: Cost of completion tokens (includes reasoning if applicable) cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools total_cost_usd_dollar: Total cost of request - additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: Cost before discount discount_percent: Discount percentage applied (0.05 = 5%) discount_amount: Discount amount in USD @@ -888,7 +838,6 @@ def _store_cost_breakdown_in_logging_obj( output_cost=completion_tokens_cost_usd_dollar, total_cost=total_cost_usd_dollar, cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools_cost_usd_dollar, - additional_costs=additional_costs, original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, @@ -1386,15 +1335,6 @@ def completion_cost( # noqa: PLR0915 service_tier=service_tier, response=completion_response, ) - - # Get additional costs from provider (e.g., routing fees, infrastructure costs) - additional_costs = _get_additional_costs( - model=model, - custom_llm_provider=custom_llm_provider, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - _final_cost = ( prompt_tokens_cost_usd_dollar + completion_tokens_cost_usd_dollar ) @@ -1434,7 +1374,6 @@ def completion_cost( # noqa: PLR0915 completion_tokens_cost_usd_dollar=completion_tokens_cost_usd_dollar, cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools, total_cost_usd_dollar=_final_cost, - additional_costs=additional_costs, original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 14015225f38..e4cfefdfb2d 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1297,7 +1297,6 @@ class Logging(LiteLLMLoggingBaseClass): output_cost: float, total_cost: float, cost_for_built_in_tools_cost_usd_dollar: float, - additional_costs: Optional[dict] = None, original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, @@ -1313,7 +1312,6 @@ class Logging(LiteLLMLoggingBaseClass): output_cost: Cost of output/completion tokens cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools total_cost: Total cost of request - additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: Cost before discount discount_percent: Discount percentage (0.05 = 5%) discount_amount: Discount amount in USD @@ -1329,10 +1327,6 @@ class Logging(LiteLLMLoggingBaseClass): tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar, ) - # Store additional costs if provided (free-form dict for extensibility) - if additional_costs and isinstance(additional_costs, dict) and len(additional_costs) > 0: - self.cost_breakdown["additional_costs"] = additional_costs - # Store discount information if provided if original_cost is not None: self.cost_breakdown["original_cost"] = original_cost diff --git a/litellm/llms/azure_ai/azure_model_router/__init__.py b/litellm/llms/azure_ai/azure_model_router/__init__.py deleted file mode 100644 index 0165d60b643..00000000000 --- a/litellm/llms/azure_ai/azure_model_router/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Azure AI Foundry Model Router support.""" -from .transformation import AzureModelRouterConfig - -__all__ = ["AzureModelRouterConfig"] diff --git a/litellm/llms/azure_ai/azure_model_router/transformation.py b/litellm/llms/azure_ai/azure_model_router/transformation.py deleted file mode 100644 index 3d6dc53c515..00000000000 --- a/litellm/llms/azure_ai/azure_model_router/transformation.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Transformation for Azure AI Foundry Model Router. - -The Model Router is a special Azure AI deployment that automatically routes requests -to the best available model. It has specific cost tracking requirements. -""" -from typing import Any, List, Optional - -from httpx import Response - -from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig -from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj -from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import ModelResponse - - -class AzureModelRouterConfig(AzureAIStudioConfig): - """ - Configuration for Azure AI Foundry Model Router. - - Handles: - - Stripping model_router prefix before sending to Azure API - - Preserving full model path in responses for cost tracking - - Calculating flat infrastructure costs for Model Router - """ - - def transform_request( - self, - model: str, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - headers: dict, - ) -> dict: - """ - Transform request for Model Router. - - Strips the model_router/ prefix so only the deployment name is sent to Azure. - Example: model_router/azure-model-router -> azure-model-router - """ - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo - - # Get base model name (strips routing prefixes like model_router/) - base_model: str = AzureFoundryModelInfo.get_base_model(model) - - return super().transform_request( - base_model, messages, optional_params, litellm_params, headers - ) - - def transform_response( - self, - model: str, - raw_response: 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: - """ - Transform response for Model Router. - - Preserves the original model path (including model_router/ prefix) in the response - for proper cost tracking and logging. - """ - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo - - # Preserve the original model from litellm_params (includes routing prefixes like model_router/) - # This ensures cost tracking and logging use the full model path - original_model: str = litellm_params.get("model") or model - if not original_model.startswith("azure_ai/"): - # Add provider prefix if not already present - model_response.model = f"azure_ai/{original_model}" - else: - model_response.model = original_model - - # Get base model for the parent call (strips routing prefixes for API compatibility) - base_model: str = AzureFoundryModelInfo.get_base_model(model) - - return super().transform_response( - model=base_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, - ) - - def calculate_additional_costs( - self, model: str, prompt_tokens: int, completion_tokens: int - ) -> Optional[dict]: - """ - Calculate additional costs for Azure Model Router. - - Adds a flat infrastructure cost of $0.14 per M input tokens for using the Model Router. - - Args: - model: The model name (should be a model router model) - prompt_tokens: Number of prompt tokens - completion_tokens: Number of completion tokens - - Returns: - Dictionary with additional costs, or None if not applicable. - """ - from litellm.llms.azure_ai.cost_calculator import ( - calculate_azure_model_router_flat_cost, - ) - - flat_cost = calculate_azure_model_router_flat_cost( - model=model, prompt_tokens=prompt_tokens - ) - - if flat_cost > 0: - return {"Azure Model Router Flat Cost": flat_cost} - - return None diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 748680f7e13..01a3f5766c6 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -13,21 +13,14 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): self._model = model @staticmethod - def get_azure_ai_route(model: str) -> Literal["agents", "model_router", "default"]: + def get_azure_ai_route(model: str) -> Literal["agents", "default"]: """ Get the Azure AI route for the given model. Similar to BedrockModelInfo.get_bedrock_route(). - - Supported routes: - - agents: azure_ai/agents/ - - model_router: azure_ai/model_router/ - - default: standard models """ if "agents/" in model: return "agents" - if "model_router/" in model or "model-router/" in model: - return "model_router" return "default" @staticmethod @@ -82,73 +75,8 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): ######################################################### @staticmethod - def strip_model_router_prefix(model: str) -> str: - """ - Strip the model_router prefix from model name. - - Examples: - - "model_router/gpt-4o" -> "gpt-4o" - - "model-router/gpt-4o" -> "gpt-4o" - - "gpt-4o" -> "gpt-4o" - - Args: - model: Model name potentially with model_router prefix - - Returns: - Model name without the prefix - """ - if "model_router/" in model: - return model.split("model_router/", 1)[1] - if "model-router/" in model: - return model.split("model-router/", 1)[1] - return model - - @staticmethod - def get_base_model(model: str) -> str: - """ - Get the base model name, stripping any Azure AI routing prefixes. - - Args: - model: Model name potentially with routing prefixes - - Returns: - Base model name - """ - # Strip model_router prefix if present - model = AzureFoundryModelInfo.strip_model_router_prefix(model) - return model - - @staticmethod - def get_azure_ai_config_for_model(model: str): - """ - Get the appropriate Azure AI config class for the given model. - - Routes to specialized configs based on model type: - - Model Router: AzureModelRouterConfig - - Claude models: AzureAnthropicConfig - - Default: AzureAIStudioConfig - - Args: - model: The model name - - Returns: - The appropriate config instance - """ - azure_ai_route = AzureFoundryModelInfo.get_azure_ai_route(model) - - if azure_ai_route == "model_router": - from litellm.llms.azure_ai.azure_model_router.transformation import ( - AzureModelRouterConfig, - ) - return AzureModelRouterConfig() - elif "claude" in model.lower(): - from litellm.llms.azure_ai.anthropic.transformation import ( - AzureAnthropicConfig, - ) - return AzureAnthropicConfig() - else: - from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig - return AzureAIStudioConfig() + def get_base_model(model: str) -> Optional[str]: + raise NotImplementedError("Azure Foundry does not support base model") def validate_environment( self, diff --git a/litellm/llms/azure_ai/cost_calculator.py b/litellm/llms/azure_ai/cost_calculator.py deleted file mode 100644 index b6258425a1f..00000000000 --- a/litellm/llms/azure_ai/cost_calculator.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Azure AI cost calculation helper. -Handles Azure AI Foundry Model Router flat cost and other Azure AI specific pricing. -""" - -from typing import Optional, Tuple - -from litellm._logging import verbose_logger -from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token -from litellm.types.utils import Usage -from litellm.utils import get_model_info - - -def _is_azure_model_router(model: str) -> bool: - """ - Check if the model is Azure AI Foundry Model Router. - - Detects patterns like: - - "azure-model-router" - - "model-router" - - "model_router/" - - "model-router/" - - Args: - model: The model name - - Returns: - bool: True if this is a model router model - """ - model_lower = model.lower() - return ( - "model-router" in model_lower - or "model_router" in model_lower - or model_lower == "azure-model-router" - ) - - -def calculate_azure_model_router_flat_cost(model: str, prompt_tokens: int) -> float: - """ - Calculate the flat cost for Azure AI Foundry Model Router. - - Args: - model: The model name (should be a model router model) - prompt_tokens: Number of prompt tokens - - Returns: - float: The flat cost in USD, or 0.0 if not applicable - """ - if not _is_azure_model_router(model): - return 0.0 - - # Get the model router pricing from model_prices_and_context_window.json - # Use "model_router" as the key (without actual model name suffix) - model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai") - router_flat_cost_per_token = model_info.get("input_cost_per_token", 0) - - if router_flat_cost_per_token > 0: - return prompt_tokens * router_flat_cost_per_token - - return 0.0 - - -def cost_per_token( - model: str, usage: Usage, response_time_ms: Optional[float] = 0.0 -) -> Tuple[float, float]: - """ - Calculate the cost per token for Azure AI models. - - For Azure AI Foundry Model Router: - - Adds a flat cost of $0.14 per million input tokens (from model_prices_and_context_window.json) - - Plus the cost of the actual model used (handled by generic_cost_per_token) - - Args: - model: str, the model name without provider prefix - usage: LiteLLM Usage block - response_time_ms: Optional response time in milliseconds - - Returns: - Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd - """ - # Calculate base cost using generic cost calculator - prompt_cost, completion_cost = generic_cost_per_token( - model=model, - usage=usage, - custom_llm_provider="azure_ai", - ) - - # Add flat cost for Azure Model Router - # The flat cost is defined in model_prices_and_context_window.json for azure_ai/azure-model-router - if _is_azure_model_router(model): - router_flat_cost = calculate_azure_model_router_flat_cost(model, usage.prompt_tokens) - - if router_flat_cost > 0: - verbose_logger.debug( - f"Azure AI Model Router flat cost: ${router_flat_cost:.6f} " - f"({usage.prompt_tokens} tokens × ${router_flat_cost / usage.prompt_tokens:.9f}/token)" - ) - - # Add flat cost to prompt cost - prompt_cost += router_flat_cost - - return prompt_cost, completion_cost diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index ac209904e6e..41a1797cebe 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -437,23 +437,3 @@ class BaseConfig(ABC): By default, this is true for almost all providers. """ return True - - def calculate_additional_costs( - self, model: str, prompt_tokens: int, completion_tokens: int - ) -> Optional[dict]: - """ - Calculate any additional costs beyond standard token costs. - - This is used for provider-specific infrastructure costs, routing fees, etc. - - Args: - model: The model name - prompt_tokens: Number of prompt tokens - completion_tokens: Number of completion tokens - - Returns: - Optional dictionary with cost names and amounts, e.g.: - {"Infrastructure Fee": 0.001, "Routing Cost": 0.0005} - Returns None if no additional costs apply. - """ - return None diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 5b110074989..dd85737a092 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1517,14 +1517,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "azure_ai/model_router": { - "input_cost_per_token": 1.4e-07, - "output_cost_per_token": 0, - "litellm_provider": "azure_ai", - "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/", - "comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/ where deployment-name is your Azure deployment (e.g., azure-model-router)" - }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, diff --git a/litellm/proxy/cached_logo.jpg b/litellm/proxy/cached_logo.jpg index a10a1d249690c6148a6ba40893e7c0ada44adec0..da4faf5b5ca6c812a73e3c1a74d93ea6f6afa682 100644 GIT binary patch literal 50535 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%0DeNB?(iW!XDl3Is3M{ws&87hHz5Vz*`;sLjzL^ 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 QPXGV_07*qoM6N<$f~<5rQUCw| literal 24694 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�R2V*J6MXq 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 F{u}R=q(uM# diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 6fd77fab7a7..136ce696511 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -623,16 +623,6 @@ class ProxyBaseLLMRequestProcessing: return self.data, logging_obj - @staticmethod - def _get_model_id_from_response(hidden_params: dict, data: dict) -> str: - """Extract model_id from hidden_params with fallback to litellm_metadata.""" - model_id = hidden_params.get("model_id", None) or "" - if not model_id: - litellm_metadata = data.get("litellm_metadata", {}) or {} - model_info = litellm_metadata.get("model_info", {}) or {} - model_id = model_info.get("id", "") or "" - return model_id - async def base_process_llm_request( self, request: Request, @@ -767,7 +757,13 @@ class ProxyBaseLLMRequestProcessing: response = responses[1] hidden_params = getattr(response, "_hidden_params", {}) or {} - model_id = self._get_model_id_from_response(hidden_params, self.data) + model_id = hidden_params.get("model_id", None) or "" + + # Fallback: extract model_id from litellm_metadata if not in hidden_params + if not model_id: + litellm_metadata = self.data.get("litellm_metadata", {}) or {} + model_info = litellm_metadata.get("model_info", {}) or {} + model_id = model_info.get("id", "") or "" cache_key, api_base, response_cost = ( hidden_params.get("cache_key", None) or "", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a00f1f605a9..078ce0edf27 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11,7 +11,7 @@ import sys import time import traceback import warnings -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta import enum from typing import ( TYPE_CHECKING, diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index f73a29dab80..867c18b6dd4 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -869,6 +869,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): try: chunk = self.litellm_custom_stream_wrapper.__next__() self._ensure_output_item_for_chunk(chunk) + # Emit any just-queued output_item event + if self._pending_response_events: + return self._pending_response_events.pop(0) self.collected_chat_completion_chunks.append(chunk) response_api_chunk = ( self._transform_chat_completion_chunk_to_response_api_chunk( @@ -876,9 +879,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ) ) if response_api_chunk: - self._pending_response_events.append(response_api_chunk) - if self._pending_response_events: - return self._pending_response_events.pop(0) + return response_api_chunk # Otherwise, loop to next chunk except StopIteration: return self.common_done_event_logic(sync_mode=True) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6c330d0f83c..f063418f92e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2637,7 +2637,6 @@ class CostBreakdown(TypedDict, total=False): ) total_cost: float # Total cost (input + output + tool usage) tool_usage_cost: float # Cost of usage of built-in tools - additional_costs: Dict[str, float] # Free-form additional costs (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: float # Cost before discount (optional) discount_percent: float # Discount percentage applied (e.g., 0.05 = 5%) (optional) discount_amount: float # Discount amount in USD (optional) diff --git a/litellm/utils.py b/litellm/utils.py index 4fcff58496c..97aeba1b086 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7921,8 +7921,9 @@ class ProviderConfigManager: @staticmethod def _get_azure_ai_config(model: str) -> BaseConfig: """Get Azure AI config based on model type.""" - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo - return AzureFoundryModelInfo.get_azure_ai_config_for_model(model) + if "claude" in model.lower(): + return litellm.AzureAnthropicConfig() + return litellm.AzureAIStudioConfig() @staticmethod def _get_vertex_ai_config(model: str) -> BaseConfig: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5b110074989..dd85737a092 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1517,14 +1517,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "azure_ai/model_router": { - "input_cost_per_token": 1.4e-07, - "output_cost_per_token": 0, - "litellm_provider": "azure_ai", - "mode": "chat", - "source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/", - "comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/ where deployment-name is your Azure deployment (e.g., azure-model-router)" - }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, diff --git a/tests/llm_translation/test_azure_ai.py b/tests/llm_translation/test_azure_ai.py index 972ba34a179..1633fb4cc82 100644 --- a/tests/llm_translation/test_azure_ai.py +++ b/tests/llm_translation/test_azure_ai.py @@ -373,17 +373,10 @@ def test_completion_azure_ai_gpt_4o_with_flexible_api_base(api_base): async def test_azure_ai_model_router(): """ Test Azure AI model router non-streaming response cost tracking. - Verifies that the flat cost of $0.14 per M input tokens is applied. - - Tests the pattern: azure_ai/model_router/ - Where deployment-name is the Azure deployment (e.g., "azure-model-router"). - The model_router prefix is stripped before sending to Azure API. """ - from litellm.llms.azure_ai.cost_calculator import calculate_azure_model_router_flat_cost - litellm._turn_on_debug() response = await litellm.acompletion( - model="azure_ai/model_router/azure-model-router", + model="azure_ai/azure-model-router", messages=[{"role": "user", "content": "hi who is this"}], api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -394,25 +387,6 @@ async def test_azure_ai_model_router(): tracked_cost = response._hidden_params["response_cost"] assert tracked_cost > 0 print("Tracked cost: ", tracked_cost) - - # Verify flat cost is included using the helper function - usage = response.usage - if usage and usage.prompt_tokens: - expected_flat_cost = calculate_azure_model_router_flat_cost( - model="model_router/azure-model-router", - prompt_tokens=usage.prompt_tokens - ) - print(f"Prompt tokens: {usage.prompt_tokens}") - print(f"Expected flat cost: ${expected_flat_cost:.9f}") - print(f"Total tracked cost: ${tracked_cost:.9f}") - - # Total cost should be at least the flat cost - assert tracked_cost >= expected_flat_cost, ( - f"Cost ${tracked_cost:.9f} should be >= flat cost ${expected_flat_cost:.9f}" - ) - - # Verify the flat cost is non-zero - assert expected_flat_cost > 0, "Flat cost should be greater than 0" @pytest.mark.asyncio diff --git a/tests/proxy_e2e_anthropic_messages_tests/test_claude_agent_sdk.py b/tests/proxy_e2e_anthropic_messages_tests/test_claude_agent_sdk.py index 317313c0553..0838faf4212 100644 --- a/tests/proxy_e2e_anthropic_messages_tests/test_claude_agent_sdk.py +++ b/tests/proxy_e2e_anthropic_messages_tests/test_claude_agent_sdk.py @@ -14,11 +14,9 @@ from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions # Test models from proxy_config.yaml -# Note: bedrock-converse-claude-sonnet-4.5 removed temporarily as the Bedrock Converse API -# for Claude Sonnet 4.5 may not be available in all regions/accounts TEST_MODELS = [ ("bedrock-claude-sonnet-4.5", "Bedrock Invoke API"), - # ("bedrock-converse-claude-sonnet-4.5", "Bedrock Converse API"), # Disabled: not yet available in CI + ("bedrock-converse-claude-sonnet-4.5", "Bedrock Converse API"), ("bedrock-nova-premier", "AWS Nova Premier"), ] diff --git a/tests/test_litellm/llms/azure_ai/test_cost_calculator.py b/tests/test_litellm/llms/azure_ai/test_cost_calculator.py deleted file mode 100644 index 03bf0a66a48..00000000000 --- a/tests/test_litellm/llms/azure_ai/test_cost_calculator.py +++ /dev/null @@ -1,345 +0,0 @@ -""" -Test Azure AI cost calculator, especially Model Router flat cost. -""" - -import pytest - -from litellm.llms.azure_ai.cost_calculator import ( - _is_azure_model_router, - cost_per_token, -) -from litellm.types.utils import Usage -from litellm.utils import get_model_info - -# Get the flat cost from model_prices_and_context_window.json -_model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai") -AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS = _model_info.get("input_cost_per_token", 0) * 1_000_000 - - -class TestAzureModelRouterDetection: - """Test that we correctly identify Azure Model Router models. - - Model Router deployments follow the pattern: model_router/ - where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router') - """ - - @pytest.mark.parametrize( - "model,expected", - [ - # Deployment names containing 'model-router' or 'model_router' - ("azure-model-router", True), - ("AZURE-MODEL-ROUTER", True), - ("model-router", True), - ("MODEL-ROUTER", True), - ("my-model-router-deployment", True), - ("prod-model_router", True), - # New pattern: model_router/ - ("model_router/azure-model-router", True), - ("model-router/prod-router", True), - ("model_router/my-deployment", True), - ("MODEL_ROUTER/AZURE-MODEL-ROUTER", True), - # Non-router models - ("gpt-4o", False), - ("gpt-4o-mini", False), - ("claude-sonnet-4-5", False), - ("my-regular-deployment", False), - ], - ) - def test_is_azure_model_router(self, model: str, expected: bool): - """Test Azure Model Router detection.""" - assert _is_azure_model_router(model) == expected - - -class TestAzureModelRouterPrefix: - """Test Azure Model Router prefix stripping.""" - - @pytest.mark.parametrize( - "model,expected", - [ - # Model router deployments - the deployment name comes after model_router/ - ("model_router/azure-model-router", "azure-model-router"), - ("model-router/my-router-deployment", "my-router-deployment"), - ("model_router/prod-router", "prod-router"), - # Non-router models - should pass through unchanged - ("gpt-4o", "gpt-4o"), - ("azure-model-router", "azure-model-router"), - ("claude-sonnet-4", "claude-sonnet-4"), - ], - ) - def test_strip_model_router_prefix(self, model: str, expected: str): - """Test that model_router prefix is stripped correctly. - - The pattern is: model_router/ - where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router') - """ - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo - - result = AzureFoundryModelInfo.strip_model_router_prefix(model) - assert result == expected - - -class TestAzureModelRouterFlatCost: - """Test Azure AI Foundry Model Router flat cost calculation.""" - - def test_model_router_flat_cost_basic(self): - """Test that flat cost is added for Model Router requests.""" - model = "azure-model-router" - usage = Usage( - prompt_tokens=1000, - completion_tokens=500, - total_tokens=1500, - ) - - prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) - - # Calculate expected flat cost - expected_flat_cost = ( - usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - - # Flat cost should be $0.00014 (1000 tokens × $0.14 / 1M tokens) - assert expected_flat_cost == pytest.approx(0.00014, rel=1e-9) - - # Prompt cost should include the flat cost - # (plus any base cost from the actual model used, which might be 0 if not in model_cost) - assert prompt_cost >= expected_flat_cost - print( - f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" - ) - print(f"Total prompt cost: ${prompt_cost:.6f}") - - def test_model_router_flat_cost_large_request(self): - """Test flat cost calculation for larger requests.""" - model = "model-router" - usage = Usage( - prompt_tokens=100_000, - completion_tokens=50_000, - total_tokens=150_000, - ) - - prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) - - # Calculate expected flat cost - expected_flat_cost = ( - usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - - # Flat cost should be $0.014 (100k tokens × $0.14 / 1M tokens) - assert expected_flat_cost == pytest.approx(0.014, rel=1e-9) - assert prompt_cost >= expected_flat_cost - print( - f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" - ) - print(f"Total prompt cost: ${prompt_cost:.6f}") - - def test_model_router_flat_cost_1m_tokens(self): - """Test flat cost for exactly 1 million input tokens.""" - model = "azure-model-router" - usage = Usage( - prompt_tokens=1_000_000, - completion_tokens=100_000, - total_tokens=1_100_000, - ) - - prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) - - # Calculate expected flat cost - expected_flat_cost = AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS - - # Flat cost should be exactly $0.14 for 1M tokens - assert expected_flat_cost == pytest.approx(0.14, rel=1e-9) - assert prompt_cost >= expected_flat_cost - print(f"Model Router flat cost for 1M tokens: ${expected_flat_cost:.6f}") - print(f"Total prompt cost: ${prompt_cost:.6f}") - - def test_non_model_router_no_flat_cost(self): - """Test that non-Model Router models don't get the flat cost.""" - model = "gpt-4o" - usage = Usage( - prompt_tokens=1000, - completion_tokens=500, - total_tokens=1500, - ) - - prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) - - # No flat cost should be added for non-Model Router models - # The cost might be 0 or based on the model's pricing - print(f"Non-Model Router prompt cost: ${prompt_cost:.6f}") - # We just ensure it doesn't crash and returns valid values - assert prompt_cost >= 0 - assert completion_cost >= 0 - - def test_model_router_with_cached_tokens(self): - """Test Model Router flat cost with cached tokens.""" - model = "azure-model-router" - usage = Usage( - prompt_tokens=2000, - completion_tokens=800, - total_tokens=2800, - cache_read_input_tokens=500, - cache_creation_input_tokens=200, - ) - - prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) - - # Flat cost is based on ALL prompt tokens (including cached) - expected_flat_cost = ( - usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - - assert expected_flat_cost == pytest.approx(0.00028, rel=1e-9) - assert prompt_cost >= expected_flat_cost - print( - f"Model Router flat cost with caching for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" - ) - print(f"Total prompt cost: ${prompt_cost:.6f}") - - -class TestAzureModelRouterCostBreakdown: - """Test that Azure Model Router flat cost is tracked in cost breakdown.""" - - def test_flat_cost_calculation_helper(self): - """Test that flat cost can be calculated using the helper function.""" - from litellm.llms.azure_ai.cost_calculator import ( - calculate_azure_model_router_flat_cost, - ) - - model = "azure-model-router" - prompt_tokens = 10000 - - # Calculate flat cost using helper function - flat_cost = calculate_azure_model_router_flat_cost( - model=model, prompt_tokens=prompt_tokens - ) - - # Expected flat cost - expected_flat_cost = ( - prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - - assert flat_cost > 0 - assert flat_cost == pytest.approx(expected_flat_cost, rel=1e-9) - print(f"Flat cost calculated: ${flat_cost:.6f}") - - def test_flat_cost_integration_with_completion_cost(self): - """Test that flat cost is properly integrated into completion_cost calculation.""" - import litellm - from litellm.cost_calculator import completion_cost - from litellm.types.utils import Choices, Message, ModelResponse, Usage - - # Create a mock response for azure_ai model router - response = ModelResponse( - id="test-123", - choices=[ - Choices( - finish_reason="stop", - index=0, - message=Message( - role="assistant", - content="Test response", - ), - ) - ], - created=1234567890, - model="azure-model-router", - object="chat.completion", - usage=Usage( - prompt_tokens=5000, - completion_tokens=2000, - total_tokens=7000, - ), - ) - - # Set hidden params for provider - response._hidden_params = {"custom_llm_provider": "azure_ai"} - - # Calculate cost - cost = completion_cost( - completion_response=response, - model="azure-model-router", - custom_llm_provider="azure_ai", - ) - - # Expected flat cost - expected_flat_cost = ( - 5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - - # Cost should include the flat cost - assert cost > expected_flat_cost - print(f"Total cost with flat fee: ${cost:.6f}") - print(f"Expected minimum flat cost: ${expected_flat_cost:.6f}") - - def test_additional_costs_in_cost_breakdown(self): - """Test that Azure Model Router flat cost appears in additional_costs dict.""" - from datetime import datetime - - from litellm.cost_calculator import completion_cost - from litellm.litellm_core_utils.litellm_logging import Logging - from litellm.types.utils import Choices, Message, ModelResponse, Usage - - # Create logging object with required parameters - logging_obj = Logging( - model="azure-model-router", - messages=[{"role": "user", "content": "Hello"}], - stream=False, - call_type="completion", - start_time=datetime.now(), - litellm_call_id="test-123", - function_id="test-function", - ) - - # Create a mock response for azure_ai model router - response = ModelResponse( - id="test-123", - choices=[ - Choices( - finish_reason="stop", - index=0, - message=Message( - role="assistant", - content="Test response", - ), - ) - ], - created=1234567890, - model="azure-model-router", - object="chat.completion", - usage=Usage( - prompt_tokens=5000, - completion_tokens=2000, - total_tokens=7000, - ), - ) - - # Set hidden params for provider - response._hidden_params = {"custom_llm_provider": "azure_ai"} - - # Calculate cost with logging object - cost = completion_cost( - completion_response=response, - model="azure-model-router", - custom_llm_provider="azure_ai", - litellm_logging_obj=logging_obj, - ) - - # Check that cost breakdown contains additional_costs - assert hasattr(logging_obj, "cost_breakdown") - assert logging_obj.cost_breakdown is not None - assert "additional_costs" in logging_obj.cost_breakdown - assert isinstance(logging_obj.cost_breakdown["additional_costs"], dict) - - # Check that the Azure Model Router flat cost is in additional_costs - additional_costs = logging_obj.cost_breakdown["additional_costs"] - assert "Azure Model Router Flat Cost" in additional_costs - - # Verify the flat cost value - expected_flat_cost = ( - 5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 - ) - actual_flat_cost = additional_costs["Azure Model Router Flat Cost"] - assert actual_flat_cost == pytest.approx(expected_flat_cost, rel=1e-9) - - print(f"Additional costs in breakdown: {additional_costs}") - print(f"Azure Model Router Flat Cost: ${actual_flat_cost:.6f}") diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index edfdeb08d82..5c1b4cbd38e 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -335,11 +335,11 @@ def test_advanced_tool_use_header_translation_for_opus_4_5(): def test_advanced_tool_use_header_filtered_for_non_opus_4_5(): """ - Test that advanced-tool-use-2025-11-20 header is filtered out for models - that don't support tool search on Bedrock. + Test that advanced-tool-use-2025-11-20 header is filtered out for non-Opus 4.5 models + without adding Bedrock-specific headers. - Tool search is supported on: Claude Opus 4.5, Claude Sonnet 4.5 - Tool search is NOT supported on: Claude 3.5 Sonnet and earlier + The translation to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 should + only happen for Claude Opus 4.5. """ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, @@ -360,9 +360,9 @@ def test_advanced_tool_use_header_filtered_for_non_opus_4_5(): "anthropic-beta": "advanced-tool-use-2025-11-20" } - # Test with Claude 3.5 Sonnet (does NOT support tool search on Bedrock) + # Test with Claude Sonnet 4.5 (not Opus 4.5) result = config.transform_anthropic_messages_request( - model="anthropic.claude-3-5-sonnet-20241022-v2:0", + model="anthropic.claude-sonnet-4-5-20250929-v1:0", messages=messages, anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, litellm_params={}, @@ -374,11 +374,11 @@ def test_advanced_tool_use_header_filtered_for_non_opus_4_5(): assert "advanced-tool-use-2025-11-20" not in beta_headers, \ "advanced-tool-use header should be removed for Bedrock" - # Verify Bedrock-specific headers were NOT added (only for Opus 4.5 and Sonnet 4.5) + # Verify Bedrock-specific headers were NOT added (only for Opus 4.5) assert "tool-search-tool-2025-10-19" not in beta_headers, \ - "tool-search-tool should not be added for models without tool search support" + "tool-search-tool should not be added for non-Opus 4.5 models" assert "tool-examples-2025-10-29" not in beta_headers, \ - "tool-examples should not be added for models without tool search support" + "tool-examples should not be added for non-Opus 4.5 models" def test_advanced_tool_use_header_translation_with_multiple_beta_headers(): diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 23de7c9b906..5074bbf4397 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1637,82 +1637,4 @@ class TestStreamingIDConsistency: # Verify it matches the cached ID assert iterator._cached_item_id is not None - assert iterator._cached_item_id == text_done_id - - def test_first_chunk_text_delta_emitted_anthropic_style(self): - """ - When the first chunk has content (e.g. Anthropic content_block_start with initial text), - that content must be emitted as output_text.delta. Previously the first chunk was - dropped because we returned output_item_added and never transformed the chunk. - """ - from unittest.mock import Mock - - import litellm - from litellm.responses.litellm_completion_transformation.streaming_iterator import ( - LiteLLMCompletionStreamingIterator, - ) - from litellm.types.llms.openai import ResponsesAPIStreamEvents - from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - - chunk_with_initial_text = ModelResponseStream( - id="msg_abc", - choices=[ - StreamingChoices( - index=0, - delta=Delta(content="I", role="assistant"), - finish_reason=None, - ) - ], - created=1234567890, - model="anthropic/claude-sonnet-4-5", - object="chat.completion.chunk", - ) - chunk_continuation = ModelResponseStream( - id="msg_abc", - choices=[ - StreamingChoices( - index=0, - delta=Delta(content="'m happy", role=None), - finish_reason=None, - ) - ], - created=1234567890, - model="anthropic/claude-sonnet-4-5", - object="chat.completion.chunk", - ) - - mock_stream_wrapper = Mock(spec=litellm.CustomStreamWrapper) - mock_stream_wrapper.logging_obj = Mock() - mock_stream_wrapper.logging_obj._response_cost_calculator = Mock(return_value=0.001) - chunk_list = [chunk_with_initial_text, chunk_continuation] - - def next_chunk(): - if not chunk_list: - raise StopIteration - return chunk_list.pop(0) - - mock_stream_wrapper.__next__ = next_chunk - - iterator = LiteLLMCompletionStreamingIterator( - model="anthropic/claude-sonnet-4-5", - litellm_custom_stream_wrapper=mock_stream_wrapper, - request_input=[{"type": "message", "role": "user", "content": [{"type": "text", "text": "repeat after me: I'm happy to be here"}]}], - responses_api_request={}, - custom_llm_provider="anthropic", - ) - - collected_deltas = [] - max_events = 20 - for i, event in enumerate(iterator): - if i >= max_events: - break - if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA and hasattr(event, "delta"): - collected_deltas.append(event.delta) - if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: - break - - full_text = "".join(collected_deltas) - assert full_text == "I'm happy", ( - f"First character from Anthropic content_block_start was truncated. " - f"Expected \"I'm happy\", got {repr(full_text)}" - ) \ No newline at end of file + assert iterator._cached_item_id == text_done_id \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx deleted file mode 100644 index de853303c15..00000000000 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; -import UserDropdown from "./UserDropdown"; - -let mockUseAuthorizedImpl = () => ({ - userId: "test-user-id", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, -}); - -let mockUseDisableShowPromptsImpl = () => false; - -let mockGetLocalStorageItemImpl = (key: string): string | null => { - if (key === "disableShowNewBadge") return null; - if (key === "disableShowPrompts") return null; - return null; -}; - -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => mockUseAuthorizedImpl(), -})); - -vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ - useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(), -})); - -vi.mock("@/utils/localStorageUtils", () => ({ - LOCAL_STORAGE_EVENT: "local-storage-change", - getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key), - setLocalStorageItem: vi.fn(), - removeLocalStorageItem: vi.fn(), - emitLocalStorageChange: vi.fn(), -})); - -describe("UserDropdown", () => { - const mockOnLogout = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockUseAuthorizedImpl = () => ({ - userId: "test-user-id", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, - }); - mockUseDisableShowPromptsImpl = () => false; - mockGetLocalStorageItemImpl = (key: string): string | null => { - if (key === "disableShowNewBadge") return null; - if (key === "disableShowPrompts") return null; - return null; - }; - }); - - it("should render", () => { - renderWithProviders(); - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("should display user button with User text", () => { - renderWithProviders(); - expect(screen.getByText("User")).toBeInTheDocument(); - }); - - it("should show user email when dropdown is opened", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - }); - - it("should show user ID when dropdown is opened", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test-user-id")).toBeInTheDocument(); - }); - }); - - it("should show user role when dropdown is opened", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("Admin")).toBeInTheDocument(); - }); - }); - - it("should display Standard badge for non-premium users", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("Standard")).toBeInTheDocument(); - }); - }); - - it("should display Premium badge for premium users", async () => { - const user = userEvent.setup(); - mockUseAuthorizedImpl = () => ({ - userId: "test-user-id", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: true, - }); - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("Premium")).toBeInTheDocument(); - }); - }); - - it("should call onLogout when logout is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - await user.click(screen.getByText("Logout")); - - expect(mockOnLogout).toHaveBeenCalledTimes(1); - }); - - it("should toggle hide new feature indicators switch", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - const toggle = screen.getByLabelText("Toggle hide new feature indicators"); - expect(toggle).not.toBeChecked(); - - await user.click(toggle); - - const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); - expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true"); - expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); - }); - - it("should toggle hide new feature indicators switch off", async () => { - const user = userEvent.setup(); - mockGetLocalStorageItemImpl = (key: string): string | null => { - if (key === "disableShowNewBadge") return "true"; - return null; - }; - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - const toggle = screen.getByLabelText("Toggle hide new feature indicators"); - expect(toggle).toBeChecked(); - - await user.click(toggle); - - const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); - expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge"); - expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); - }); - - it("should toggle hide all prompts switch", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - const toggle = screen.getByLabelText("Toggle hide all prompts"); - expect(toggle).not.toBeChecked(); - - await user.click(toggle); - - const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); - expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts", "true"); - expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts"); - }); - - it("should toggle hide all prompts switch off", async () => { - const user = userEvent.setup(); - mockUseDisableShowPromptsImpl = () => true; - mockGetLocalStorageItemImpl = (key: string): string | null => { - if (key === "disableShowPrompts") return "true"; - return null; - }; - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - const toggle = screen.getByLabelText("Toggle hide all prompts"); - expect(toggle).toBeChecked(); - - await user.click(toggle); - - const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); - expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts"); - expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts"); - }); - - it("should display dash when user email is not available", async () => { - const user = userEvent.setup(); - mockUseAuthorizedImpl = () => ({ - userId: "test-user-id", - userEmail: null as any, - userRole: "Admin", - premiumUser: false, - }); - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("-")).toBeInTheDocument(); - }); - }); - - it("should display dash when user ID is not available", async () => { - const user = userEvent.setup(); - mockUseAuthorizedImpl = () => ({ - userId: null as any, - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, - }); - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - const dashElements = screen.getAllByText("-"); - expect(dashElements.length).toBeGreaterThan(0); - }); - }); - - it("should initialize hide new feature indicators from localStorage", async () => { - const user = userEvent.setup(); - mockGetLocalStorageItemImpl = (key: string): string | null => { - if (key === "disableShowNewBadge") return "true"; - return null; - }; - - renderWithProviders(); - - await user.click(screen.getByText("User")); - - await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); - }); - - const toggle = screen.getByLabelText("Toggle hide new feature indicators"); - expect(toggle).toBeChecked(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx deleted file mode 100644 index f80af33f9e2..00000000000 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; -import { - emitLocalStorageChange, - getLocalStorageItem, - removeLocalStorageItem, - setLocalStorageItem, -} from "@/utils/localStorageUtils"; -import { - CrownOutlined, - DownOutlined, - LogoutOutlined, - MailOutlined, - SafetyOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; - -const { Text } = Typography; - -interface UserDropdownProps { - onLogout: () => void; -} - -const UserDropdown: React.FC = ({ onLogout }) => { - const { userId, userEmail, userRole, premiumUser } = useAuthorized(); - const disableShowPrompts = useDisableShowPrompts(); - const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); - - useEffect(() => { - const storedValue = getLocalStorageItem("disableShowNewBadge"); - setDisableShowNewBadge(storedValue === "true"); - }, []); - - const userItems: MenuProps["items"] = [ - { - key: "logout", - label: ( - - - Logout - - ), - onClick: onLogout, - }, - ]; - - const renderUserInfoSection = () => ( - - - - - {userEmail || "-"} - - {premiumUser ? ( - } - color="gold" - > - Premium - - ) : ( - - } - > - Standard - - - )} - - - - - - User ID - - - {userId || "-"} - - - - - - Role - - {userRole} - - - - Hide New Feature Indicators - { - setDisableShowNewBadge(checked); - if (checked) { - setLocalStorageItem("disableShowNewBadge", "true"); - emitLocalStorageChange("disableShowNewBadge"); - } else { - removeLocalStorageItem("disableShowNewBadge"); - emitLocalStorageChange("disableShowNewBadge"); - } - }} - aria-label="Toggle hide new feature indicators" - /> - - - Hide All Prompts - { - if (checked) { - setLocalStorageItem("disableShowPrompts", "true"); - emitLocalStorageChange("disableShowPrompts"); - } else { - removeLocalStorageItem("disableShowPrompts"); - emitLocalStorageChange("disableShowPrompts"); - } - }} - aria-label="Toggle hide all prompts" - /> - - - ); - - return ( - ( -
- {renderUserInfoSection()} - - {React.cloneElement(menu as React.ReactElement, { - style: { boxShadow: "none" }, - })} -
- )} - > - -
- ); -}; - -export default UserDropdown; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx index c48b9a755b7..5a012c1fc5c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx @@ -32,7 +32,7 @@ const FeatureCard: React.FC = ({ description, children, serverName, - accessGroups = ["dev-group"], + accessGroups = ["dev"], }) => { const [useServerHeader, setUseServerHeader] = useState(false); @@ -42,9 +42,9 @@ const FeatureCard: React.FC = ({ }; if (useServerHeader && serverName) { const formattedServerName = serverName.replace(/\s+/g, "_"); - // Include both server name and access groups in the same header (comma-separated string) + // Include both server name and access groups in the same header const serverAndGroups = [formattedServerName, ...accessGroups].join(","); - headers["x-mcp-servers"] = serverAndGroups; + headers["x-mcp-servers"] = [serverAndGroups]; } return headers; }; @@ -77,13 +77,13 @@ const FeatureCard: React.FC = ({ description={

- Option 1: Get a specific server: "{serverName.replace(/\s+/g, "_")}" + Option 1: Get a specific server: ["{serverName.replace(/\s+/g, "_")}"]

- Option 2: Get a group of MCPs: "dev-group" + Option 2: Get a group of MCPs: ["dev-group"]

- You can also mix both: "Server1,dev-group" + You can also mix both: ["Server1,dev-group"]

} @@ -144,8 +144,8 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] // Format server names (replace spaces with underscores) const formattedServers = serverHeaders[type].map((s) => s.replace(/\s+/g, "_")); - // Use comma-separated string (can include both servers and access groups) - headers["x-mcp-servers"] = formattedServers.join(","); + // Use comma-separated format (can include both servers and access groups) + headers["x-mcp-servers"] = [formattedServers.join(",")]; } return headers; @@ -244,7 +244,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] title="Implementation Example" description="Complete cURL example for using the LiteLLM Proxy Responses API" serverName={currentServer} - accessGroups={["dev-group"]} + accessGroups={["dev"]} > = ({ currentServerAccessGroups = [] "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_VIRTUAL_KEY", - "x-mcp-servers": "Zapier_MCP,dev-group" + "x-mcp-servers": ["Zapier_MCP,dev"] } } ], @@ -327,7 +327,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] title="Implementation Example" description="Complete cURL example for using the Responses API" serverName="Zapier Gmail" - accessGroups={["dev-group"]} + accessGroups={["dev"]} > = ({ currentServerAccessGroups = [] "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", - "x-mcp-servers": "Zapier_MCP,dev-group" + "x-mcp-servers": ["Zapier_MCP,dev"] } } ], @@ -400,7 +400,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] title="Configuration" description="Cursor MCP configuration" serverName="Zapier Gmail" - accessGroups={["dev-group"]} + accessGroups={["dev"]} > = ({ currentServerAccessGroups = [] "url": "${proxyBaseUrl}/mcp", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", - "x-mcp-servers": "Zapier_MCP,dev-group" + "x-mcp-servers": ["Zapier_MCP,dev"] } } } diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index a2996f70587..9fa32cf9cb0 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -15,14 +15,8 @@ vi.mock("@/utils/proxyUtils", () => ({ // Create mock functions that can be controlled in tests let mockUseThemeImpl = () => ({ logoUrl: null as string | null }); let mockUseHealthReadinessImpl = () => ({ data: null as any }); -let mockGetLocalStorageItemImpl = (key: string) => null as string | null; +let mockGetLocalStorageItemImpl = () => null as string | null; let mockUseDisableShowPromptsImpl = () => false; -let mockUseAuthorizedImpl = () => ({ - userId: "test-user", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, -}); vi.mock("@/contexts/ThemeContext", () => ({ useTheme: () => mockUseThemeImpl(), @@ -36,13 +30,9 @@ vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(), })); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => mockUseAuthorizedImpl(), -})); - vi.mock("@/utils/localStorageUtils", () => ({ LOCAL_STORAGE_EVENT: "local-storage-change", - getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key), + getLocalStorageItem: () => mockGetLocalStorageItemImpl(), setLocalStorageItem: vi.fn(), removeLocalStorageItem: vi.fn(), emitLocalStorageChange: vi.fn(), @@ -133,27 +123,14 @@ describe("Navbar", () => { it("should show premium user badge when premiumUser is true", async () => { const user = userEvent.setup(); - mockUseAuthorizedImpl = () => ({ - userId: "test-user", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: true, - }); - renderWithProviders(); + const premiumProps = { ...defaultProps, premiumUser: true }; + renderWithProviders(); await user.click(screen.getByText("User")); await waitFor(() => { expect(screen.getByText("Premium")).toBeInTheDocument(); }); - - // Reset mock - mockUseAuthorizedImpl = () => ({ - userId: "test-user", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, - }); }); it("should show version badge when health data contains version", () => { @@ -190,10 +167,7 @@ describe("Navbar", () => { const user = userEvent.setup(); // Initially disabled - mockGetLocalStorageItemImpl = (key: string) => { - if (key === "disableShowNewBadge") return "false"; - return null; - }; + mockGetLocalStorageItemImpl = () => "false"; renderWithProviders(); @@ -212,9 +186,6 @@ describe("Navbar", () => { const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true"); expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); - - // Reset mock - mockGetLocalStorageItemImpl = (key: string) => null; }); it("should handle logout functionality", async () => { diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 3649ca76238..8e4fda0ba52 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,20 +1,32 @@ import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; +import { + emitLocalStorageChange, + getLocalStorageItem, + removeLocalStorageItem, + setLocalStorageItem, +} from "@/utils/localStorageUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; import { + CrownOutlined, GithubOutlined, + LogoutOutlined, + MailOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, + SafetyOutlined, SlackOutlined, SunOutlined, + UserOutlined, } from "@ant-design/icons"; -import { Button, Switch, Tag } from "antd"; +import type { MenuProps } from "antd"; +import { Button, Dropdown, Switch, Tooltip } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; -import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; interface NavbarProps { userID: string | null; @@ -47,6 +59,8 @@ const Navbar: React.FC = ({ }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); + const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); + const disableShowPrompts = useDisableShowPrompts(); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadiness(); const version = healthData?.litellm_version; @@ -68,6 +82,11 @@ const Navbar: React.FC = ({ initializeProxySettings(); }, [accessToken]); + useEffect(() => { + const storedValue = getLocalStorageItem("disableShowNewBadge"); + setDisableShowNewBadge(storedValue === "true"); + }, []); + useEffect(() => { setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || ""); }, [proxySettings]); @@ -77,6 +96,105 @@ const Navbar: React.FC = ({ window.location.href = logoutUrl; }; + const userItems: MenuProps["items"] = [ + { + key: "user-info", + // Prevent dropdown from closing when interacting with the toggle + onClick: (info) => info.domEvent?.stopPropagation(), + label: ( +
+
+
+ + {userID} +
+ {premiumUser ? ( + +
+ + Premium +
+
+ ) : ( + +
+ + Standard +
+
+ )} +
+
+
+ + Role + {userRole} +
+
+ + Email + + {userEmail || "Unknown"} + +
+
e.stopPropagation()} + > + Hide New Feature Indicators + { + setDisableShowNewBadge(checked); + if (checked) { + setLocalStorageItem("disableShowNewBadge", "true"); + emitLocalStorageChange("disableShowNewBadge"); + } else { + removeLocalStorageItem("disableShowNewBadge"); + emitLocalStorageChange("disableShowNewBadge"); + } + }} + aria-label="Toggle hide new feature indicators" + /> +
+
e.stopPropagation()} + > + Hide All Prompts + { + if (checked) { + setLocalStorageItem("disableShowPrompts", "true"); + emitLocalStorageChange("disableShowPrompts"); + } else { + removeLocalStorageItem("disableShowPrompts"); + emitLocalStorageChange("disableShowPrompts"); + } + }} + aria-label="Toggle hide all prompts" + /> +
+
+
+ ), + }, + { + key: "logout", + label: ( +
+ + Logout +
+ ), + }, + ]; + return (