diff --git a/litellm/constants.py b/litellm/constants.py index 1d42ef9a910..6a67a9a0e18 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -909,6 +909,9 @@ BEDROCK_CONVERSE_MODELS = [ "meta.llama3-2-3b-instruct-v1:0", "meta.llama3-2-11b-instruct-v1:0", "meta.llama3-2-90b-instruct-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-2-lite-v1:0", + "amazon.nova-pro-v1:0", ] diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 3b3a138ec67..705f3c9e630 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -246,6 +246,93 @@ class AmazonConverseConfig(BaseConfig): llm_provider="bedrock", ) + def _is_nova_lite_2_model(self, model: str) -> bool: + """ + Check if the model is a Nova Lite 2 model that supports reasoningConfig. + + Nova Lite 2 models use a different reasoning configuration structure compared to + Anthropic's thinking parameter and GPT-OSS's reasoning_effort parameter. + + Supported models: + - amazon.nova-2-lite-v1:0 + - us.amazon.nova-2-lite-v1:0 + - eu.amazon.nova-2-lite-v1:0 + - apac.amazon.nova-2-lite-v1:0 + + Args: + model: The model identifier + + Returns: + True if the model is a Nova Lite 2 model, False otherwise + + Examples: + >>> config = AmazonConverseConfig() + >>> config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0") + True + >>> config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0") + True + >>> config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0") + False + >>> config._is_nova_lite_2_model("amazon.nova-pro-v1:0") + False + """ + # Remove regional prefix if present (us., eu., apac.) + model_without_region = model + for prefix in ["us.", "eu.", "apac."]: + if model.startswith(prefix): + model_without_region = model[len(prefix) :] + break + + # Check if the model is specifically Nova Lite 2 + return "nova-2-lite" in model_without_region + + def _transform_reasoning_effort_to_reasoning_config( + self, reasoning_effort: str + ) -> dict: + """ + Transform reasoning_effort parameter to Nova 2 reasoningConfig structure. + + Nova 2 models use a reasoningConfig structure in additionalModelRequestFields + that differs from both Anthropic's thinking parameter and GPT-OSS's reasoning_effort. + + Args: + reasoning_effort: The reasoning effort level, must be "low" or "high" + + Returns: + dict: A dictionary containing the reasoningConfig structure: + { + "reasoningConfig": { + "type": "enabled", + "maxReasoningEffort": "low" | "medium" |"high" + } + } + + Raises: + BadRequestError: If reasoning_effort is not "low", "medium" or "high" + + Examples: + >>> config = AmazonConverseConfig() + >>> config._transform_reasoning_effort_to_reasoning_config("high") + {'reasoningConfig': {'type': 'enabled', 'maxReasoningEffort': 'high'}} + >>> config._transform_reasoning_effort_to_reasoning_config("low") + {'reasoningConfig': {'type': 'enabled', 'maxReasoningEffort': 'low'}} + """ + valid_values = ["low", "medium", "high"] + if reasoning_effort not in valid_values: + raise litellm.exceptions.BadRequestError( + message=f"Invalid reasoning_effort value '{reasoning_effort}' for Nova 2 models. " + f"Supported values: {valid_values}", + model="amazon.nova-2-lite-v1:0", + llm_provider="bedrock_converse", + ) + + return { + "reasoningConfig": { + "type": "enabled", + "maxReasoningEffort": reasoning_effort, + } + } + def get_supported_openai_params(self, model: str) -> List[str]: from litellm.utils import supports_function_calling @@ -299,6 +386,10 @@ class AmazonConverseConfig(BaseConfig): if "gpt-oss" in model: supported_params.append("reasoning_effort") + elif self._is_nova_lite_2_model(model): + # Nova Lite 2 models support reasoning_effort (transformed to reasoningConfig) + # These models use a different reasoning structure than Anthropic's thinking parameter + supported_params.append("reasoning_effort") elif ( "claude-3-7" in model or "claude-sonnet-4" in model @@ -564,6 +655,12 @@ class AmazonConverseConfig(BaseConfig): # GPT-OSS models: keep reasoning_effort as-is # It will be passed through to additionalModelRequestFields optional_params["reasoning_effort"] = value + elif self._is_nova_lite_2_model(model): + # Nova Lite 2 models: transform to reasoningConfig + reasoning_config = ( + self._transform_reasoning_effort_to_reasoning_config(value) + ) + optional_params.update(reasoning_config) else: # Anthropic and other models: convert to thinking parameter optional_params["thinking"] = AnthropicConfig._map_reasoning_effort( @@ -574,8 +671,9 @@ class AmazonConverseConfig(BaseConfig): self._validate_request_metadata(value) # type: ignore optional_params["requestMetadata"] = value - # Only update thinking tokens for non-GPT-OSS models - if "gpt-oss" not in model: + # Only update thinking tokens for non-GPT-OSS models and non-Nova-Lite-2 models + # Nova Lite 2 handles token budgeting differently through reasoningConfig + if "gpt-oss" not in model and not self._is_nova_lite_2_model(model): self.update_optional_params_with_thinking_tokens( non_default_params=non_default_params, optional_params=optional_params ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f3398e470d8..932508824af 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10421,6 +10421,19 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fireworks_ai/accounts/fireworks/models/firefunction-v2": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f3398e470d8..19ed734c5f8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -269,6 +269,71 @@ "supports_response_schema": true, "supports_vision": true }, + "amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "apac.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "eu.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "us.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "amazon.nova-micro-v1:0": { "input_cost_per_token": 3.5e-08, "litellm_provider": "bedrock_converse", @@ -10421,6 +10486,19 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fireworks_ai/accounts/fireworks/models/firefunction-v2": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 37c95be72ce..e603f94ab87 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -2702,3 +2702,36 @@ def test_empty_assistant_message_handling(): finally: # Restore original modify_params setting litellm.modify_params = original_modify_params + + +def test_is_nova_lite_2_model(): + """Test the _is_nova_lite_2_model() method for detecting Nova 2 models.""" + config = AmazonConverseConfig() + + # Test with amazon.nova-2-lite-v1:0 + assert config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0") is True + + # Test with regional variants + assert config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0") is True + assert config._is_nova_lite_2_model("eu.amazon.nova-2-lite-v1:0") is True + assert config._is_nova_lite_2_model("apac.amazon.nova-2-lite-v1:0") is True + + # Test with other Nova 2 variants (pro, micro) + assert config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-micro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("us.amazon.nova-pro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("eu.amazon.nova-micro-1-5-v1:0") is False + + # Test with non-Nova-1.5 lite models (should return False) + assert config._is_nova_lite_2_model("amazon.nova-lite-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-pro-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-micro-v1:0") is False + + # Test with Nova v1:0 models (should return False) + assert config._is_nova_lite_2_model("us.amazon.nova-lite-v1:0") is False + assert config._is_nova_lite_2_model("eu.amazon.nova-pro-v1:0") is False + + # Test with completely different models (should return False) + assert config._is_nova_lite_2_model("anthropic.claude-3-5-sonnet-20240620-v1:0") is False + assert config._is_nova_lite_2_model("meta.llama3-70b-instruct-v1:0") is False + assert config._is_nova_lite_2_model("mistral.mistral-7b-instruct-v0:2") is False diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py new file mode 100644 index 00000000000..23243dac201 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py @@ -0,0 +1,794 @@ +""" +Unit tests for Amazon Nova 2 reasoning configuration transformation. + +Tests the _transform_reasoning_effort_to_reasoning_config method in AmazonConverseConfig. +""" + +import pytest +import sys +import os + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + + +class TestNova15ReasoningTransformation: + """Test suite for Nova 2 reasoning effort transformation.""" + + def test_reasoning_effort_low_transformation(self): + """Test that reasoning_effort='low' is transformed to correct reasoningConfig structure.""" + config = AmazonConverseConfig() + + result = config._transform_reasoning_effort_to_reasoning_config("low") + + # Verify the structure + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + def test_reasoning_effort_high_transformation(self): + """Test that reasoning_effort='high' is transformed to correct reasoningConfig structure.""" + config = AmazonConverseConfig() + + result = config._transform_reasoning_effort_to_reasoning_config("high") + + # Verify the structure + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_invalid_reasoning_effort_value(self): + """Test that invalid reasoning_effort values raise BadRequestError.""" + config = AmazonConverseConfig() + + # Test with invalid value "invalid" + with pytest.raises(litellm.exceptions.BadRequestError) as exc_info: + config._transform_reasoning_effort_to_reasoning_config("invalid") + + # Verify error message contains the invalid value and valid values + error_message = str(exc_info.value) + assert "invalid" in error_message + assert "low" in error_message + assert "high" in error_message + assert "Nova 2" in error_message + + def test_invalid_reasoning_effort_empty_string(self): + """Test that empty string raises BadRequestError.""" + config = AmazonConverseConfig() + + with pytest.raises(litellm.exceptions.BadRequestError) as exc_info: + config._transform_reasoning_effort_to_reasoning_config("") + + # Verify error message + error_message = str(exc_info.value) + assert "low" in error_message + assert "high" in error_message + + def test_invalid_reasoning_effort_wrong_case(self): + """Test that case-sensitive values are rejected (e.g., 'Low' instead of 'low').""" + config = AmazonConverseConfig() + + with pytest.raises(litellm.exceptions.BadRequestError): + config._transform_reasoning_effort_to_reasoning_config("Low") + + with pytest.raises(litellm.exceptions.BadRequestError): + config._transform_reasoning_effort_to_reasoning_config("HIGH") + + +class TestNova2ParameterMapping: + """Test suite for Nova 2 parameter mapping integration.""" + + def test_nova_2_reasoning_effort_low_mapping(self): + """Test that reasoning_effort='low' is correctly mapped to reasoningConfig for Nova 2.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "low"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT kept as-is (should be transformed) + assert "reasoning_effort" not in result + + def test_nova_2_reasoning_effort_high_mapping(self): + """Test that reasoning_effort='high' is correctly mapped to reasoningConfig for Nova 2.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT kept as-is (should be transformed) + assert "reasoning_effort" not in result + + def test_nova_2_without_reasoning_effort(self): + """Test that Nova 2 without reasoning_effort has no reasoningConfig in result.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"temperature": 0.7} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is NOT in result + assert "reasoningConfig" not in result + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT in result + assert "reasoning_effort" not in result + + def test_nova_2_regional_variant_us(self): + """Test that US regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "us.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_nova_2_regional_variant_eu(self): + """Test that EU regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "eu.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "low"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + def test_nova_2_regional_variant_apac(self): + """Test that APAC regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "apac.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_nova_2_with_other_params(self): + """Test that Nova 2 reasoning works alongside other parameters.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = { + "reasoning_effort": "high", + "temperature": 0.8, + "max_tokens": 1000, + "top_p": 0.9, + } + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + # Verify other params are also present + assert result["temperature"] == 0.8 + assert result["maxTokens"] == 1000 + assert result["topP"] == 0.9 + + +class TestNova15SupportedParameters: + """Test suite for Nova 2 supported parameters.""" + + def test_nova_2_supports_reasoning_effort(self): + """Test that Nova 2 model reports reasoning_effort in supported params.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params (Nova 2 uses reasoningConfig, not thinking) + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_us_supported_params(self): + """Test that US regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "us.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_eu_supported_params(self): + """Test that EU regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "eu.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_apac_supported_params(self): + """Test that APAC regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "apac.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_has_standard_params(self): + """Test that Nova 2 still has all standard supported params.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify standard params are present + assert "max_tokens" in supported_params + assert "max_completion_tokens" in supported_params + assert "stream" in supported_params + assert "stream_options" in supported_params + assert "stop" in supported_params + assert "temperature" in supported_params + assert "top_p" in supported_params + assert "tools" in supported_params + assert "response_format" in supported_params + + +class TestNova15ResponseParsing: + """Test suite for Nova 2 response parsing.""" + + def test_transform_reasoning_content_single_block(self): + """Test that reasoning content is extracted correctly from a single block.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "Let me think through this step by step..."}} + ] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert result == "Let me think through this step by step..." + + def test_transform_reasoning_content_multiple_blocks(self): + """Test that reasoning content is concatenated from multiple blocks.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "First, I need to analyze the problem. "}}, + {"reasoningText": {"text": "Then, I'll consider the solution."}}, + ] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert ( + result + == "First, I need to analyze the problem. Then, I'll consider the solution." + ) + + def test_transform_reasoning_content_empty_blocks(self): + """Test that empty reasoning blocks return empty string.""" + config = AmazonConverseConfig() + + reasoning_blocks = [] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert result == "" + + def test_transform_thinking_blocks_with_text(self): + """Test that thinking blocks are populated correctly with text.""" + config = AmazonConverseConfig() + + reasoning_blocks = [{"reasoningText": {"text": "My reasoning process..."}}] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "My reasoning process..." + assert "signature" not in result[0] + + def test_transform_thinking_blocks_with_signature(self): + """Test that signature field is preserved when present.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + { + "reasoningText": { + "text": "My reasoning...", + "signature": "signature-hash-12345", + } + } + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "My reasoning..." + assert result[0]["signature"] == "signature-hash-12345" + + def test_transform_thinking_blocks_with_redacted_content(self): + """Test that redacted content blocks are handled correctly.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "First part of reasoning..."}}, + {"redactedContent": {}}, + {"reasoningText": {"text": "Second part after redaction..."}}, + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 3 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "First part of reasoning..." + assert result[1]["type"] == "redacted_thinking" + assert result[2]["type"] == "thinking" + assert result[2]["thinking"] == "Second part after redaction..." + + def test_transform_thinking_blocks_multiple_blocks(self): + """Test that multiple thinking blocks are all transformed.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "Step 1: Analyze the problem"}}, + { + "reasoningText": { + "text": "Step 2: Consider solutions", + "signature": "sig-abc", + } + }, + {"reasoningText": {"text": "Step 3: Choose best approach"}}, + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 3 + assert all(block["type"] == "thinking" for block in result) + assert result[0]["thinking"] == "Step 1: Analyze the problem" + assert result[1]["thinking"] == "Step 2: Consider solutions" + assert result[1]["signature"] == "sig-abc" + assert result[2]["thinking"] == "Step 3: Choose best approach" + + def test_transform_thinking_blocks_empty_list(self): + """Test that empty thinking blocks list returns empty list.""" + config = AmazonConverseConfig() + + reasoning_blocks = [] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert result == [] + + def test_response_parsing_integration(self): + """Test that response parsing works end-to-end with Nova 2 structure.""" + config = AmazonConverseConfig() + + # Simulate a Nova 2 response with reasoning content + reasoning_blocks = [ + { + "reasoningText": { + "text": "Let me analyze this carefully. ", + "signature": "test-signature", + } + }, + {"reasoningText": {"text": "Based on my analysis, the answer is clear."}}, + ] + + # Test reasoning content extraction + reasoning_content = config._transform_reasoning_content(reasoning_blocks) + assert ( + reasoning_content + == "Let me analyze this carefully. Based on my analysis, the answer is clear." + ) + + # Test thinking blocks transformation + thinking_blocks = config._transform_thinking_blocks(reasoning_blocks) + assert len(thinking_blocks) == 2 + assert thinking_blocks[0]["thinking"] == "Let me analyze this carefully. " + assert thinking_blocks[0]["signature"] == "test-signature" + assert ( + thinking_blocks[1]["thinking"] + == "Based on my analysis, the answer is clear." + ) + + +class TestNova15StreamingResponseParsing: + """Test suite for Nova 2 streaming response parsing.""" + + def test_streaming_reasoning_content_start_event(self): + """Test that streaming start event with reasoningContent is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a start event with redacted reasoning content + chunk_data = { + "start": {"reasoningContent": {"redactedContent": {}}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify thinking blocks are populated + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking" + + def test_streaming_reasoning_content_delta_text(self): + """Test that streaming delta event with reasoning text is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with reasoning text + chunk_data = { + "delta": {"reasoningContent": {"text": "Let me think about this..."}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is extracted + assert result.choices[0].delta.reasoning_content == "Let me think about this..." + + # Verify thinking blocks are populated + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + assert ( + result.choices[0].delta.thinking_blocks[0]["thinking"] + == "Let me think about this..." + ) + + def test_streaming_reasoning_content_delta_signature(self): + """Test that streaming delta event with signature is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with signature + chunk_data = { + "delta": {"reasoningContent": {"signature": "signature-hash-xyz"}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is set to empty string for consistency + assert result.choices[0].delta.reasoning_content == "" + + # Verify thinking blocks are populated with signature + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + assert ( + result.choices[0].delta.thinking_blocks[0]["signature"] + == "signature-hash-xyz" + ) + assert result.choices[0].delta.thinking_blocks[0]["thinking"] == "" + + def test_streaming_reasoning_content_multiple_deltas(self): + """Test that multiple reasoning content deltas are accumulated correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate multiple delta events + chunks = [ + { + "delta": {"reasoningContent": {"text": "First, "}}, + "contentBlockIndex": 0, + }, + { + "delta": {"reasoningContent": {"text": "I need to analyze "}}, + "contentBlockIndex": 0, + }, + { + "delta": {"reasoningContent": {"text": "the problem."}}, + "contentBlockIndex": 0, + }, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify each delta has the correct reasoning content + assert results[0].choices[0].delta.reasoning_content == "First, " + assert results[1].choices[0].delta.reasoning_content == "I need to analyze " + assert results[2].choices[0].delta.reasoning_content == "the problem." + + # Verify thinking blocks are populated for each delta + for result in results: + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + + def test_streaming_reasoning_then_text_content(self): + """Test that reasoning content followed by text content is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate reasoning content followed by text content + chunks = [ + { + "delta": {"reasoningContent": {"text": "Let me think..."}}, + "contentBlockIndex": 0, + }, + {"delta": {"text": "Based on my reasoning, "}, "contentBlockIndex": 1}, + {"delta": {"text": "the answer is 42."}, "contentBlockIndex": 1}, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify first chunk has reasoning content + assert results[0].choices[0].delta.reasoning_content == "Let me think..." + assert results[0].choices[0].delta.thinking_blocks is not None + + # Verify subsequent chunks have text content + assert results[1].choices[0].delta.content == "Based on my reasoning, " + assert results[2].choices[0].delta.content == "the answer is 42." + + def test_streaming_redacted_content_delta(self): + """Test that streaming delta with redacted content is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with redacted content + chunk_data = { + "delta": {"reasoningContent": {"redactedContent": {}}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is set to empty string for consistency + assert result.choices[0].delta.reasoning_content == "" + + # Verify thinking blocks contain redacted block + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking" + + def test_streaming_provider_specific_fields(self): + """Test that provider_specific_fields are populated in streaming responses.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with reasoning content + chunk_data = { + "delta": {"reasoningContent": {"text": "Reasoning text"}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify provider_specific_fields are populated + assert result.choices[0].delta.provider_specific_fields is not None + assert "reasoningContent" in result.choices[0].delta.provider_specific_fields + assert ( + result.choices[0].delta.provider_specific_fields["reasoningContent"]["text"] + == "Reasoning text" + ) + + def test_streaming_mixed_content_blocks(self): + """Test streaming with mixed content blocks (reasoning, text, tool calls).""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a complex streaming scenario + chunks = [ + # Start with reasoning + { + "delta": { + "reasoningContent": { + "text": "I need to call a tool to get information." + } + }, + "contentBlockIndex": 0, + }, + # Tool use start + { + "start": {"toolUse": {"toolUseId": "tool-123", "name": "get_weather"}}, + "contentBlockIndex": 1, + }, + # Tool use delta + { + "delta": {"toolUse": {"input": '{"location": "NYC"}'}}, + "contentBlockIndex": 1, + }, + # Text response + {"delta": {"text": "The weather is sunny."}, "contentBlockIndex": 2}, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify reasoning content in first chunk + assert ( + results[0].choices[0].delta.reasoning_content + == "I need to call a tool to get information." + ) + + # Verify tool call in second and third chunks + assert results[1].choices[0].delta.tool_calls is not None + assert ( + results[1].choices[0].delta.tool_calls[0]["function"]["name"] + == "get_weather" + ) + assert results[2].choices[0].delta.tool_calls is not None + + # Verify text content in fourth chunk + assert results[3].choices[0].delta.content == "The weather is sunny." + + def test_extract_reasoning_content_str_with_text(self): + """Test extract_reasoning_content_str method with text.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + reasoning_block = {"text": "This is reasoning text"} + + result = handler.extract_reasoning_content_str(reasoning_block) + + assert result == "This is reasoning text" + + def test_extract_reasoning_content_str_without_text(self): + """Test extract_reasoning_content_str method without text (e.g., signature only).""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + reasoning_block = {"signature": "sig-123"} + + result = handler.extract_reasoning_content_str(reasoning_block) + + assert result is None + + def test_translate_thinking_blocks_streaming_text(self): + """Test translate_thinking_blocks method with text.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"text": "Thinking content"} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "Thinking content" + + def test_translate_thinking_blocks_streaming_signature(self): + """Test translate_thinking_blocks method with signature.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"signature": "sig-abc"} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["signature"] == "sig-abc" + assert ( + result[0]["thinking"] == "" + ) # Empty string for consistency with Anthropic + + def test_translate_thinking_blocks_streaming_redacted(self): + """Test translate_thinking_blocks method with redacted content.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"redactedContent": {}} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "redacted_thinking" diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index 1878e1364d0..a791ece9bb7 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -38,10 +38,9 @@ const ModelSection = ({ modelName, metrics }: { modelName: string; metrics: Mode - {/* Top API Keys Section */} {metrics.top_api_keys && metrics.top_api_keys.length > 0 && ( - Top API Keys by Spend + Top Virtual Keys by Spend
{metrics.top_api_keys.map((keyData, index) => ( @@ -384,12 +383,12 @@ export const processActivityData = ( }); }); - // Process API key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) + // Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) if (key !== "api_keys") { Object.entries(modelMetrics).forEach(([model, _]) => { const apiKeyBreakdown: Record = {}; - // Aggregate API key data across all days + // Aggregate Virtual Key data across all days dailyActivity.results.forEach((day) => { const modelData = day.breakdown[key]?.[model]; if (modelData && "api_key_breakdown" in modelData) { diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index d34b14ceabf..a8046d146a8 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -569,7 +569,7 @@ const BulkCreateUsersButton: React.FC = ({
  • Download our CSV template
  • Add your users' information to the spreadsheet
  • Save the file and upload it here
  • -
  • After creation, download the results file containing the API keys for each user
  • +
  • After creation, download the results file containing the Virtual Keys for each user
  • @@ -809,9 +809,9 @@ const BulkCreateUsersButton: React.FC = ({
    User creation complete - Next step: Download the credentials file containing API - keys and invitation links. Users will need these API keys to make LLM requests through - LiteLLM. + Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM.
    diff --git a/ui/litellm-dashboard/src/components/cache_dashboard.tsx b/ui/litellm-dashboard/src/components/cache_dashboard.tsx index a1c0cb0a664..38c0f1a8f41 100644 --- a/ui/litellm-dashboard/src/components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/cache_dashboard.tsx @@ -293,7 +293,11 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + {uniqueApiKeys.map((key) => ( {key} @@ -388,11 +392,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole /> - + diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx index c42094abb55..c63770d3c85 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx @@ -21,7 +21,7 @@ const PassThroughSecuritySection: React.FC = ({ Security - When enabled, requests to this endpoint will require a valid LiteLLM API key + When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key {premiumUser ? ( @@ -35,22 +35,13 @@ const PassThroughSecuritySection: React.FC = ({ ) : (
    - + Authentication (Premium)
    Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "} - + here . @@ -63,4 +54,3 @@ const PassThroughSecuritySection: React.FC = ({ }; export default PassThroughSecuritySection; - diff --git a/ui/litellm-dashboard/src/components/dashboard_default_team.tsx b/ui/litellm-dashboard/src/components/dashboard_default_team.tsx index 36805b8912a..6506e1a60a5 100644 --- a/ui/litellm-dashboard/src/components/dashboard_default_team.tsx +++ b/ui/litellm-dashboard/src/components/dashboard_default_team.tsx @@ -69,7 +69,8 @@ const DashboardTeam: React.FC = ({ Select Team - If you belong to multiple teams, this setting controls which team is used by default when creating new API Keys. + If you belong to multiple teams, this setting controls which team is used by default when creating new Virtual + Keys. Default Team: If no team_id is set for a key, it will be grouped under here. diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index a5789b7dbac..501eac7124b 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -550,7 +550,7 @@ const EntityUsage: React.FC = ({ {/* Top API Keys */} - Top API Keys + Top Virtual Keys = ({ setLoading(true); try { const agentIdsToMakePublic = Array.from(selectedAgents); - + // Make batch API call for all agents await makeAgentsPublicCall(accessToken, agentIdsToMakePublic); @@ -127,8 +127,8 @@ const MakeAgentPublicForm: React.FC = ({
    - Select the agents you want to be visible on the public model hub. Users will still require a valid API key to - use these agents. + Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key + to use these agents.
    @@ -141,10 +141,7 @@ const MakeAgentPublicForm: React.FC = ({ agentHubData.map((agent) => { const agentId = agent.agent_id || agent.name; return ( -
    +
    handleAgentSelection(agentId, e.target.checked)} @@ -217,9 +214,7 @@ const MakeAgentPublicForm: React.FC = ({ )}
    - {agent?.description && ( - {agent.description} - )} + {agent?.description && {agent.description}}
    ); @@ -296,4 +291,3 @@ const MakeAgentPublicForm: React.FC = ({ }; export default MakeAgentPublicForm; - diff --git a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx index 29f866f8bc6..f7bba175800 100644 --- a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx +++ b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx @@ -76,7 +76,7 @@ const MakeMCPPublicForm: React.FC = ({ const publicServerIds = mcpHubData .filter((server) => server.mcp_info?.is_public === true) .map((server) => server.server_id); - + // Preselect servers that are already public setSelectedServers(new Set(publicServerIds)); } @@ -91,7 +91,7 @@ const MakeMCPPublicForm: React.FC = ({ setLoading(true); try { const serverIdsToMakePublic = Array.from(selectedServers); - + // Make batch API call for all servers await makeMCPPublicCall(accessToken, serverIdsToMakePublic); @@ -128,8 +128,8 @@ const MakeMCPPublicForm: React.FC = ({
    - Select the MCP servers you want to be visible on the public model hub. Users will still require a valid API key to - use these servers. + Select the MCP servers you want to be visible on the public model hub. Users will still require a valid + Virtual Key to use these servers.
    @@ -161,22 +161,20 @@ const MakeMCPPublicForm: React.FC = ({ {server.transport} - {server.status || "unknown"}
    - - {server.description || server.url} - + {server.description || server.url} {server.allowed_tools && server.allowed_tools.length > 0 && (
    {server.allowed_tools.slice(0, 3).map((tool, idx) => ( @@ -236,14 +234,14 @@ const MakeMCPPublicForm: React.FC = ({ {server.transport} - {server.status || "unknown"} @@ -251,12 +249,8 @@ const MakeMCPPublicForm: React.FC = ({ )}
    - {server?.description && ( - {server.description} - )} - {server?.url && ( - {server.url} - )} + {server?.description && {server.description}} + {server?.url && {server.url}}
    ); @@ -267,8 +261,8 @@ const MakeMCPPublicForm: React.FC = ({
    - Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made - public + Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be + made public
    @@ -333,4 +327,3 @@ const MakeMCPPublicForm: React.FC = ({ }; export default MakeMCPPublicForm; - diff --git a/ui/litellm-dashboard/src/components/make_model_public_form.tsx b/ui/litellm-dashboard/src/components/make_model_public_form.tsx index e67d60fb33b..750bdc24eeb 100644 --- a/ui/litellm-dashboard/src/components/make_model_public_form.tsx +++ b/ui/litellm-dashboard/src/components/make_model_public_form.tsx @@ -152,8 +152,8 @@ const MakeModelPublicForm: React.FC = ({ - Select the models you want to be visible on the public model hub. Users will still require a valid API key to - use these models. + Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key + to use these models. {/* Filters */} 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 4b4f1ab676b..5a012c1fc5c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx @@ -220,12 +220,12 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] } - title="API Key Setup" - description="Configure your LiteLLM Proxy API key for authentication" + title="Virtual Key Setup" + description="Configure your LiteLLM Proxy Virtual Key for authentication" >
    - Get your API key from your LiteLLM Proxy dashboard or contact your administrator + Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator
    @@ -249,7 +249,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] = ({ currentServerAccessGroups = [] "server_url": "${proxyBaseUrl}/mcp", "require_approval": "never", "headers": { - "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", + "x-litellm-api-key": "Bearer YOUR_LITELLM_VIRTUAL_KEY", "x-mcp-servers": ["Zapier_MCP,dev"] } } diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx new file mode 100644 index 00000000000..aee7a0cdd1d --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import AddCredentialModal from "./AddCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +describe("AddCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Add New Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should show the correct provider fields", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx new file mode 100644 index 00000000000..694a98201c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx @@ -0,0 +1,118 @@ +import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import React, { useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; + +interface AddCredentialsModalProps { + open: boolean; + onCancel: () => void; + onAddCredential: (values: any) => void; + uploadProps: UploadProps; +} + +const AddCredentialsModal: React.FC = ({ open, onCancel, onAddCredential, uploadProps }) => { + const [form] = Form.useForm(); + const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); + + const handleSubmit = (values: any) => { + const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { + if (value !== "" && value !== undefined && value !== null) { + acc[key] = value; + } + return acc; + }, {} as any); + onAddCredential(filteredValues); + form.resetFields(); + }; + + return ( + { + onCancel(); + form.resetFields(); + }} + footer={null} + width={600} + > +
    + {/* Credential Name */} + + + + + {/* Provider Selection */} + + { + setSelectedProvider(value as Providers); + form.setFieldValue("custom_llm_provider", value); + }} + > + {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( + +
    + {`${providerEnum} { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + const fallbackDiv = document.createElement("div"); + fallbackDiv.className = + "w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"; + fallbackDiv.textContent = providerDisplayName.charAt(0); + parent.replaceChild(fallbackDiv, target); + } + }} + /> + {providerDisplayName} +
    +
    + ))} +
    +
    + + + + {/* Modal Footer */} +
    + + Need Help? + + +
    + + +
    +
    + +
    + ); +}; + +export default AddCredentialsModal; diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx new file mode 100644 index 00000000000..def3b4f6cd7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import { CredentialItem } from "../networking"; +import EditCredentialModal from "./EditCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +const mockCredential: CredentialItem = { + credential_name: "test-credential", + credential_values: { + api_key: "test-api-key", + api_base: "https://api.test.com", + }, + credential_info: { + custom_llm_provider: Providers.OpenAI, + }, +}; + +describe("EditCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Edit Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should render initial values", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement; + expect(credentialNameInput.value).toBe("test-credential"); + expect(credentialNameInput.disabled).toBe(true); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx similarity index 78% rename from ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx rename to ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx index 9c061eb121a..b206ed6c91d 100644 --- a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx @@ -1,34 +1,29 @@ -import React, { useEffect, useState } from "react"; -import { Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd"; -import type { UploadProps } from "antd/es/upload"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; -import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import { useEffect, useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { CredentialItem } from "../networking"; -const { Title, Link } = Typography; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; -interface AddCredentialsModalProps { - isVisible: boolean; +interface EditCredentialsModalProps { + open: boolean; onCancel: () => void; - onAddCredential: (values: any) => void; onUpdateCredential: (values: any) => void; uploadProps: UploadProps; - addOrEdit: "add" | "edit"; existingCredential: CredentialItem | null; } -const AddCredentialsModal: React.FC = ({ - isVisible, +export default function EditCredentialsModal({ + open, onCancel, - onAddCredential, onUpdateCredential, uploadProps, - addOrEdit, existingCredential, -}) => { +}: EditCredentialsModalProps) { const [form] = Form.useForm(); - const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); const handleSubmit = (values: any) => { const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { @@ -37,23 +32,25 @@ const AddCredentialsModal: React.FC = ({ } return acc; }, {} as any); - if (addOrEdit === "add") { - onAddCredential(filteredValues); - } else { - onUpdateCredential(filteredValues); - } + onUpdateCredential(filteredValues); form.resetFields(); }; useEffect(() => { if (existingCredential) { + // Spread all credential_values dynamically, converting undefined/null to null for form compatibility + const credentialValues = Object.entries(existingCredential.credential_values || {}).reduce( + (acc, [key, value]) => { + acc[key] = value ?? null; + return acc; + }, + {} as Record, + ); + form.setFieldsValue({ credential_name: existingCredential.credential_name, custom_llm_provider: existingCredential.credential_info.custom_llm_provider, - api_base: existingCredential.credential_values.api_base, - api_version: existingCredential.credential_values.api_version, - base_model: existingCredential.credential_values.base_model, - api_key: existingCredential.credential_values.api_key, + ...credentialValues, }); setSelectedProvider(existingCredential.credential_info.custom_llm_provider as Providers); } @@ -61,14 +58,15 @@ const AddCredentialsModal: React.FC = ({ return ( { onCancel(); form.resetFields(); }} footer={null} width={600} + destroyOnHidden={true} >
    {/* Credential Name */} @@ -142,12 +140,10 @@ const AddCredentialsModal: React.FC = ({ > Cancel - +
    ); -}; - -export default AddCredentialsModal; +} diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index cf6a5cb9eef..73e5ce80585 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -19,10 +19,9 @@ import { } from "@tremor/react"; import { Form } from "antd"; import { UploadProps } from "antd/es/upload"; -import React, { useEffect, useState } from "react"; -import DeleteResourceModal from "../common_components/DeleteResourceModal"; import NotificationsManager from "../molecules/notifications_manager"; -import AddCredentialsTab from "./add_credentials_tab"; +import AddCredentialsTab from "./AddCredentialModal"; +import EditCredentialsModal from "./EditCredentialModal"; interface CredentialsPanelProps { accessToken: string | null; uploadProps: UploadProps; @@ -62,10 +61,10 @@ const CredentialsPanel: React.FC = ({ }, }; - const response = await credentialUpdateCall(accessToken, values.credential_name, newCredential); + await credentialUpdateCall(accessToken, values.credential_name, newCredential); NotificationsManager.success("Credential updated successfully"); setIsUpdateModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; const handleAddCredential = async (values: any) => { @@ -86,10 +85,10 @@ const CredentialsPanel: React.FC = ({ }; // Add to list and close modal - const response = await credentialCreateCall(accessToken, newCredential); + await credentialCreateCall(accessToken, newCredential); NotificationsManager.success("Credential added successfully"); setIsAddModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; useEffect(() => { @@ -201,23 +200,18 @@ const CredentialsPanel: React.FC = ({ {isAddModalOpen && ( setIsAddModalOpen(false)} uploadProps={uploadProps} - addOrEdit="add" - onUpdateCredential={handleUpdateCredential} - existingCredential={null} /> )} {isUpdateModalOpen && ( - setIsUpdateModalOpen(false)} - addOrEdit="edit" /> )} diff --git a/ui/litellm-dashboard/src/components/new_usage.test.tsx b/ui/litellm-dashboard/src/components/new_usage.test.tsx index a4969124f1f..a06045137d7 100644 --- a/ui/litellm-dashboard/src/components/new_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.test.tsx @@ -239,7 +239,7 @@ describe("NewUsage", () => { // Check for chart titles expect(screen.getByText("Daily Spend")).toBeInTheDocument(); - expect(screen.getByText("Top API Keys")).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument(); }); it("should switch between tabs correctly", async () => { diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index 4794a7f091d..a8d30885493 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -580,7 +580,7 @@ const NewUsagePage: React.FC = ({ {/* Top API Keys */} - Top API Keys + Top Virtual Keys = ({ setApiKey(response["key"]); setSoftBudget(response["soft_budget"]); - NotificationsManager.success("API Key Created"); + NotificationsManager.success("Virtual Key Created"); form.resetFields(); localStorage.removeItem("userData" + userID); } catch (error) { @@ -415,7 +415,7 @@ const CreateKey: React.FC = ({ }; const handleCopy = () => { - NotificationsManager.success("API Key copied to clipboard"); + NotificationsManager.success("Virtual Key copied to clipboard"); }; useEffect(() => { @@ -505,7 +505,7 @@ const CreateKey: React.FC = ({ label={ Owned By{" "} - + @@ -594,8 +594,8 @@ const CreateKey: React.FC = ({ {isFormDisabled && (
    - Please select a team to continue configuring your API key. If you do not see any teams, please contact - your Proxy Admin to either provide you with access to models or to add you to a team. + Please select a team to continue configuring your Virtual Key. If you do not see any teams, please + contact your Proxy Admin to either provide you with access to models or to add you to a team.
    )} @@ -1277,7 +1277,7 @@ const CreateKey: React.FC = ({ {apiKey != null ? (
    - API Key: + Virtual Key:
    = ({
    - + {/*
    - New API Key: + New Virtual Key:
    {regeneratedKey}
    NotificationManager.success("API Key copied to clipboard")} + onCopy={() => NotificationManager.success("Virtual Key copied to clipboard")} > - + diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index 90c43df0dc4..c2924c048c3 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -691,7 +691,7 @@ const ChatUI: React.FC = ({ const effectiveApiKey = apiKeySource === "session" ? accessToken : apiKey; if (!effectiveApiKey) { - NotificationsManager.fromBackend("Please provide an API key or select Current UI Session"); + NotificationsManager.fromBackend("Please provide a Virtual Key or select Current UI Session"); return; } @@ -1003,7 +1003,7 @@ const ChatUI: React.FC = ({
    - API Key Source + Virtual Key Source setApiKeySource(value as "session" | "custom")} @@ -567,7 +567,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: setCustomApiKey(event.target.value)} - placeholder="Enter API key" + placeholder="Enter Virtual Key" className="w-56" /> )} diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx index 47941bce2ca..3f8c90424c6 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx @@ -20,7 +20,7 @@ export async function makeAnthropicMessagesRequest( selectedMCPTools?: string[], ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } const isLocal = process.env.NODE_ENV === "development"; diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx index 832d29bb852..d0939c00437 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx @@ -9,7 +9,7 @@ export async function makeOpenAIEmbeddingsRequest( tags?: string[], ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } // Base URL should be the current base_url diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx index 8461f8e20a0..46b0621a0b1 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx @@ -24,7 +24,7 @@ export async function makeOpenAIResponsesRequest( onMCPEvent?: (event: MCPEvent) => void, ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } // Base URL should be the current base_url diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index f58e392a58f..9897bb4d47a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -245,7 +245,7 @@ import KeyInfoView from "./key_info_view"; const baseKeyData = { token_id: "tok_123", token: "tok_123", - key_alias: "My API Key", + key_alias: "My Virtual Key", key_name: "sk-xxxx", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index ec2b294d9de..dbbb195a1f8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -297,7 +297,7 @@ export default function KeyInfoView({ - {currentKeyData.key_alias || "API Key"} + {currentKeyData.key_alias || "Virtual Key"}
    @@ -381,7 +381,7 @@ export default function KeyInfoView({ {/* Delete Confirmation Modal */} {isDeleteModalOpen && (() => { - const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key"; + const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "Virtual Key"; const isValid = deleteConfirmInput === keyName; return (
    @@ -415,7 +415,7 @@ export default function KeyInfoView({

    - Warning: You are about to delete this API key. + Warning: You are about to delete this Virtual Key.

    This action is irreversible and will immediately revoke access for any applications using this @@ -423,7 +423,7 @@ export default function KeyInfoView({

    -

    Are you sure you want to delete this API key?

    +

    Are you sure you want to delete this Virtual Key?

    - Warning: You are about to delete this API key. + Warning: You are about to delete this Virtual Key.

    This action is irreversible and will immediately revoke access for any applications using this @@ -374,7 +374,7 @@ const ViewKeyTable: React.FC = ({

    -

    Are you sure you want to delete this API key?

    +

    Are you sure you want to delete this Virtual Key?

    @@ -417,7 +417,7 @@ const ViewKeyTable: React.FC = ({ {/* Regenerate Key Form Modal */} { setRegenerateDialogVisible(false); @@ -516,7 +516,7 @@ const ViewKeyTable: React.FC = ({ {selectedToken?.key_alias || "No alias set"}
    - New API Key: + New Virtual Key:
    = ({
    NotificationManager.success({ description: "API Key copied to clipboard" })} + onCopy={() => NotificationManager.success({ description: "Virtual Key copied to clipboard" })} > - + diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/components/usage.tsx index 88b1e3c3fb7..0900a0a9cc1 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/components/usage.tsx @@ -615,7 +615,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use - Top API Keys + Top Virtual Keys {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, }, + { + header: "User Alias", + accessorKey: "user_alias", + enableSorting: false, + cell: ({ row }) => {row.original.user_alias || "-"}, + }, { header: "Spend (USD)", accessorKey: "spend", @@ -78,14 +84,14 @@ export const columns = ( ), }, { - header: "API Keys", + header: "Virtual Keys", accessorKey: "key_count", enableSorting: false, cell: ({ row }) => ( {row.original.key_count > 0 ? ( - {row.original.key_count} Keys + {row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"} ) : ( diff --git a/ui/litellm-dashboard/src/components/view_users/table.test.tsx b/ui/litellm-dashboard/src/components/view_users/table.test.tsx index 8ef887932cc..c688d5749d0 100644 --- a/ui/litellm-dashboard/src/components/view_users/table.test.tsx +++ b/ui/litellm-dashboard/src/components/view_users/table.test.tsx @@ -1,63 +1,52 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; - import { UserDataTable } from "./table"; +const defaultFilters = { + email: "", + user_id: "", + user_role: "", + sso_user_id: "", + team: "", + model: "", + min_spend: null, + max_spend: null, + sort_by: "", + sort_order: "asc" as const, +}; + +const getDefaultProps = () => ({ + data: [] as any[], + columns: [] as any[], + accessToken: null, + userRole: "Admin", + possibleUIRoles: null as Record> | null, + filters: defaultFilters, + updateFilters: vi.fn(), + initialFilters: defaultFilters, + teams: [] as any[], + handleEdit: vi.fn(), + handleDelete: vi.fn(), + handleResetPassword: vi.fn(), + userListResponse: { users: [], total: 0, page: 1, page_size: 25, total_pages: 1 }, + currentPage: 1, + handlePageChange: vi.fn(), +}); + describe("UserDataTable", () => { it("should render the UserDataTable component", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.getByText("Filters")).toBeInTheDocument(); }); it("should call onSortChange when clicking a sortable header", () => { const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, + ...defaultFilters, sort_by: "created_at", sort_order: "desc" as const, }; - const updateFilters = vi.fn(); const onSortChange = vi.fn(); const possibleUIRoles = { @@ -67,21 +56,10 @@ describe("UserDataTable", () => { render( , @@ -96,41 +74,7 @@ describe("UserDataTable", () => { }); it("should show skeleton loaders when isLoading is true", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.queryByText(/Showing/i)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Previous/i })).not.toBeInTheDocument(); @@ -138,44 +82,35 @@ describe("UserDataTable", () => { }); it("should show actual content when isLoading is false", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.getByText(/Showing/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Previous/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument(); }); + + it("should render all column headers", () => { + const possibleUIRoles = { + admin: { ui_label: "Admin" }, + user: { ui_label: "User" }, + }; + + render(); + + [ + "User ID", + "Email", + "Global Proxy Role", + "User Alias", + "Spend (USD)", + "Budget (USD)", + "SSO ID", + "API Keys", + "Created At", + "Updated At", + "Actions", + ].forEach((header) => { + expect(screen.getByRole("columnheader", { name: header })).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_users/types.ts b/ui/litellm-dashboard/src/components/view_users/types.ts index d976d46ebc1..d674db5c7db 100644 --- a/ui/litellm-dashboard/src/components/view_users/types.ts +++ b/ui/litellm-dashboard/src/components/view_users/types.ts @@ -1,6 +1,7 @@ export interface UserInfo { user_id: string; user_email: string; + user_alias: string | null; user_role: string; spend: number; max_budget: number | null; diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx index 456f07d1882..2caae7d861f 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -320,9 +320,11 @@ export default function UserInfoView({ - API Keys + Virtual Keys
    - {userData.keys?.length || 0} keys + + {userData.keys?.length || 0} {userData.keys?.length === 1 ? "Key" : "Keys"} +
    @@ -467,7 +469,7 @@ export default function UserInfoView({
    - API Keys + Virtual Keys
    {userData.keys?.length && userData.keys?.length > 0 ? ( userData.keys.map((key, index) => ( @@ -476,7 +478,7 @@ export default function UserInfoView({ )) ) : ( - No API keys + No Virtual Keys )}