Merge remote-tracking branch 'origin' into litellm_ui_cred_refresh

This commit is contained in:
yuneng-jiang 2025-12-02 17:29:22 -08:00
commit 4006987f0e
38 changed files with 1574 additions and 295 deletions

View file

@ -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",
]

View file

@ -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
)

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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"

View file

@ -38,10 +38,9 @@ const ModelSection = ({ modelName, metrics }: { modelName: string; metrics: Mode
</Card>
</Grid>
{/* Top API Keys Section */}
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
<Card className="mt-4">
<Title>Top API Keys by Spend</Title>
<Title>Top Virtual Keys by Spend</Title>
<div className="mt-3">
<div className="grid grid-cols-1 gap-2">
{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<string, TopApiKeyData> = {};
// 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) {

View file

@ -569,7 +569,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the API keys for each user</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
@ -809,9 +809,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
<div>
<Text className="font-medium text-blue-800">User creation complete</Text>
<Text className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing API
keys and invitation links. Users will need these API keys to make LLM requests through
LiteLLM.
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</Text>
</div>
</div>

View file

@ -293,7 +293,11 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
<Card>
<Grid numItems={3} className="gap-4 mt-4">
<Col>
<MultiSelect placeholder="Select API Keys" value={selectedApiKeys} onValueChange={setSelectedApiKeys}>
<MultiSelect
placeholder="Select Virtual Keys"
value={selectedApiKeys}
onValueChange={setSelectedApiKeys}
>
{uniqueApiKeys.map((key) => (
<MultiSelectItem key={key} value={key}>
{key}
@ -388,11 +392,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
/>
</TabPanel>
<TabPanel>
<CacheSettings
accessToken={accessToken}
userRole={userRole}
userID={userID}
/>
<CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} />
</TabPanel>
</TabPanels>
</TabGroup>

View file

@ -21,7 +21,7 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
<Card className="p-6">
<Title className="text-lg font-semibold text-gray-900 mb-2">Security</Title>
<Subtitle className="text-gray-600 mb-4">
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
</Subtitle>
{premiumUser ? (
<Form.Item name="auth" valuePropName="checked" className="mb-0">
@ -35,22 +35,13 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
) : (
<div>
<div className="flex items-center mb-3">
<Switch
disabled
checked={false}
style={{ outline: '2px solid #d1d5db', outlineOffset: '2px' }}
/>
<Switch disabled checked={false} style={{ outline: "2px solid #d1d5db", outlineOffset: "2px" }} />
<span className="ml-2 text-sm text-gray-400">Authentication (Premium)</span>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<Text className="text-sm text-yellow-800">
Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
here
</a>
.
@ -63,4 +54,3 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
};
export default PassThroughSecuritySection;

View file

@ -69,7 +69,8 @@ const DashboardTeam: React.FC<DashboardTeamProps> = ({
<Title>Select Team</Title>
<Text>
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.
</Text>
<Text className="mt-3 mb-3">
<b>Default Team:</b> If no team_id is set for a key, it will be grouped under here.

View file

@ -550,7 +550,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{/* Top API Keys */}
<Col numColSpan={1}>
<Card>
<Title>Top API Keys</Title>
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={getTopAPIKeys()}
accessToken={accessToken}

View file

@ -90,7 +90,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
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<MakeAgentPublicFormProps> = ({
</div>
<Text className="text-sm text-gray-600">
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.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
@ -141,10 +141,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
agentHubData.map((agent) => {
const agentId = agent.agent_id || agent.name;
return (
<div
key={agentId}
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
>
<div key={agentId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
checked={selectedAgents.has(agentId)}
onChange={(e) => handleAgentSelection(agentId, e.target.checked)}
@ -217,9 +214,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
</Badge>
)}
</div>
{agent?.description && (
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
)}
{agent?.description && <Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>}
</div>
</div>
);
@ -296,4 +291,3 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
};
export default MakeAgentPublicForm;

View file

@ -76,7 +76,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
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<MakeMCPPublicFormProps> = ({
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<MakeMCPPublicFormProps> = ({
</div>
<Text className="text-sm text-gray-600">
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.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
@ -161,22 +161,20 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<Badge color="blue" size="sm">
{server.transport}
</Badge>
<Badge
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
? "red"
: "gray"
}
size="sm"
>
{server.status || "unknown"}
</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">
{server.description || server.url}
</Text>
<Text className="text-xs text-gray-600 mt-1">{server.description || server.url}</Text>
{server.allowed_tools && server.allowed_tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{server.allowed_tools.slice(0, 3).map((tool, idx) => (
@ -236,14 +234,14 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<Badge color="blue" size="xs">
{server.transport}
</Badge>
<Badge
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
? "red"
: "gray"
}
size="xs"
>
{server.status || "unknown"}
@ -251,12 +249,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</>
)}
</div>
{server?.description && (
<Text className="text-xs text-gray-600 mt-1">{server.description}</Text>
)}
{server?.url && (
<Text className="text-xs text-gray-500 mt-1">{server.url}</Text>
)}
{server?.description && <Text className="text-xs text-gray-600 mt-1">{server.description}</Text>}
{server?.url && <Text className="text-xs text-gray-500 mt-1">{server.url}</Text>}
</div>
</div>
);
@ -267,8 +261,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
Total: <strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be made
public
Total: <strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be
made public
</Text>
</div>
</div>
@ -333,4 +327,3 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
};
export default MakeMCPPublicForm;

View file

@ -152,8 +152,8 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
</div>
<Text className="text-sm text-gray-600">
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.
</Text>
{/* Filters */}

View file

@ -220,12 +220,12 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
<Space direction="vertical" size="large" className="w-full">
<FeatureCard
icon={<KeyIcon className="text-emerald-600" size={16} />}
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"
>
<Space direction="vertical" size="middle" className="w-full">
<div>
<Text>Get your API key from your LiteLLM Proxy dashboard or contact your administrator</Text>
<Text>Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator</Text>
</div>
<CodeBlock title="Environment Variable" code='export LITELLM_API_KEY="sk-..."' copyKey="litellm-env" />
</Space>
@ -249,7 +249,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
<CodeBlock
code={`curl --location '${proxyBaseUrl}/v1/responses' \\
--header 'Content-Type: application/json' \\
--header "Authorization: Bearer $LITELLM_API_KEY" \\
--header "Authorization: Bearer $LITELLM_VIRTUAL_KEY" \\
--data '{
"model": "gpt-4",
"tools": [
@ -259,7 +259,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ 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"]
}
}

View file

@ -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(
<QueryClientProvider client={queryClient}>
<AddCredentialModal
open={true}
onCancel={onCancel}
onAddCredential={onAddCredential}
uploadProps={mockUploadProps}
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<AddCredentialModal
open={true}
onCancel={onCancel}
onAddCredential={onAddCredential}
uploadProps={mockUploadProps}
/>
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument();
expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument();
});
});
});

View file

@ -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<AddCredentialsModalProps> = ({ open, onCancel, onAddCredential, uploadProps }) => {
const [form] = Form.useForm();
const [selectedProvider, setSelectedProvider] = useState<Providers>(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 (
<Modal
title="Add New Credential"
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
footer={null}
width={600}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
{/* Credential Name */}
<Form.Item
label="Credential Name:"
name="credential_name"
rules={[{ required: true, message: "Credential name is required" }]}
>
<TextInput placeholder="Enter a friendly name for these credentials" />
</Form.Item>
{/* Provider Selection */}
<Form.Item
rules={[{ required: true, message: "Required" }]}
label="Provider:"
name="custom_llm_provider"
tooltip="Helper to auto-populate provider specific fields"
>
<AntdSelect
showSearch
onChange={(value) => {
setSelectedProvider(value as Providers);
form.setFieldValue("custom_llm_provider", value);
}}
>
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => (
<AntdSelect.Option key={providerEnum} value={providerEnum}>
<div className="flex items-center space-x-2">
<img
src={providerLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
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);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</AntdSelect.Option>
))}
</AntdSelect>
</Form.Item>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
{/* Modal Footer */}
<div className="flex justify-between items-center">
<Tooltip title="Get help on our github">
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
</Tooltip>
<div>
<Button
onClick={() => {
onCancel();
form.resetFields();
}}
style={{ marginRight: 10 }}
>
Cancel
</Button>
<Button htmlType="submit">{"Add Credential"}</Button>
</div>
</div>
</Form>
</Modal>
);
};
export default AddCredentialsModal;

View file

@ -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(
<QueryClientProvider client={queryClient}>
<EditCredentialModal
open={true}
onCancel={onCancel}
onUpdateCredential={onUpdateCredential}
uploadProps={mockUploadProps}
existingCredential={mockCredential}
/>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<EditCredentialModal
open={true}
onCancel={onCancel}
onUpdateCredential={onUpdateCredential}
uploadProps={mockUploadProps}
existingCredential={mockCredential}
/>
</QueryClientProvider>,
);
await waitFor(() => {
const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement;
expect(credentialNameInput.value).toBe("test-credential");
expect(credentialNameInput.disabled).toBe(true);
});
});
});

View file

@ -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<AddCredentialsModalProps> = ({
isVisible,
export default function EditCredentialsModal({
open,
onCancel,
onAddCredential,
onUpdateCredential,
uploadProps,
addOrEdit,
existingCredential,
}) => {
}: EditCredentialsModalProps) {
const [form] = Form.useForm();
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.OpenAI);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
const handleSubmit = (values: any) => {
const filteredValues = Object.entries(values).reduce((acc, [key, value]) => {
@ -37,23 +32,25 @@ const AddCredentialsModal: React.FC<AddCredentialsModalProps> = ({
}
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<string, any>,
);
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<AddCredentialsModalProps> = ({
return (
<Modal
title={addOrEdit === "add" ? "Add New Credential" : "Edit Credential"}
visible={isVisible}
title="Edit Credential"
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
footer={null}
width={600}
destroyOnHidden={true}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
{/* Credential Name */}
@ -142,12 +140,10 @@ const AddCredentialsModal: React.FC<AddCredentialsModalProps> = ({
>
Cancel
</Button>
<Button htmlType="submit">{addOrEdit === "add" ? "Add Credential" : "Update Credential"}</Button>
<Button htmlType="submit">{"Update Credential"}</Button>
</div>
</div>
</Form>
</Modal>
);
};
export default AddCredentialsModal;
}

View file

@ -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<CredentialsPanelProps> = ({
},
};
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<CredentialsPanelProps> = ({
};
// 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<CredentialsPanelProps> = ({
{isAddModalOpen && (
<AddCredentialsTab
onAddCredential={handleAddCredential}
isVisible={isAddModalOpen}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
uploadProps={uploadProps}
addOrEdit="add"
onUpdateCredential={handleUpdateCredential}
existingCredential={null}
/>
)}
{isUpdateModalOpen && (
<AddCredentialsTab
onAddCredential={handleAddCredential}
isVisible={isUpdateModalOpen}
<EditCredentialsModal
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onUpdateCredential={handleUpdateCredential}
uploadProps={uploadProps}
onCancel={() => setIsUpdateModalOpen(false)}
addOrEdit="edit"
/>
)}

View file

@ -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 () => {

View file

@ -580,7 +580,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
{/* Top API Keys */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top API Keys</Title>
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={getTopKeys()}
accessToken={accessToken}

View file

@ -405,7 +405,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
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<CreateKeyProps> = ({
};
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<CreateKeyProps> = ({
label={
<span>
Owned By{" "}
<Tooltip title="Select who will own this API key">
<Tooltip title="Select who will own this Virtual Key">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
@ -594,8 +594,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({
{isFormDisabled && (
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-md">
<Text className="text-blue-800 text-sm">
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.
</Text>
</div>
)}
@ -1277,7 +1277,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
<Col numColSpan={1}>
{apiKey != null ? (
<div>
<Text className="mt-3">API Key:</Text>
<Text className="mt-3">Virtual Key:</Text>
<div
style={{
background: "#f8f8f8",
@ -1290,7 +1290,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
</div>
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
<Button className="mt-3">Copy API Key</Button>
<Button className="mt-3">Copy Virtual Key</Button>
</CopyToClipboard>
{/* <Button className="mt-3" onClick={sendSlackAlert}>
Test Key

View file

@ -113,7 +113,7 @@ export function RegenerateKeyModal({
formValues,
);
setRegeneratedKey(response.key);
NotificationManager.success("API Key regenerated successfully");
NotificationManager.success("Virtual Key regenerated successfully");
console.log("Full regenerate response:", response); // Debug log to see what's returned
@ -164,7 +164,7 @@ export function RegenerateKeyModal({
return (
<Modal
title="Regenerate API Key"
title="Regenerate Virtual Key"
open={visible}
onCancel={handleClose}
footer={
@ -199,15 +199,15 @@ export function RegenerateKeyModal({
<div className="bg-gray-100 p-2 rounded mb-2">
<pre className="break-words whitespace-normal">{selectedToken?.key_alias || "No alias set"}</pre>
</div>
<Text className="mt-3">New API Key:</Text>
<Text className="mt-3">New Virtual Key:</Text>
<div className="bg-gray-100 p-2 rounded mb-2">
<pre className="break-words whitespace-normal">{regeneratedKey}</pre>
</div>
<CopyToClipboard
text={regeneratedKey}
onCopy={() => NotificationManager.success("API Key copied to clipboard")}
onCopy={() => NotificationManager.success("Virtual Key copied to clipboard")}
>
<Button className="mt-3">Copy API Key</Button>
<Button className="mt-3">Copy Virtual Key</Button>
</CopyToClipboard>
</Col>
</Grid>

View file

@ -691,7 +691,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
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<ChatUIProps> = ({
<div className="space-y-4">
<div>
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<KeyOutlined className="mr-2" /> API Key Source
<KeyOutlined className="mr-2" /> Virtual Key Source
</Text>
<Select
disabled={disabledPersonalKeyCreation}
@ -1021,7 +1021,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
{apiKeySource === "custom" && (
<TextInput
className="mt-2"
placeholder="Enter custom API key"
placeholder="Enter custom Virtual Key"
type="password"
onValueChange={setApiKey}
value={apiKey}

View file

@ -395,7 +395,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
return;
}
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;
}
const targetComparisons = comparisons;
@ -551,7 +551,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="border-b px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">API Key Source</span>
<span className="text-sm font-medium text-gray-600">Virtual Key Source</span>
<Select
value={apiKeySource}
onChange={(value) => setApiKeySource(value as "session" | "custom")}
@ -567,7 +567,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<Input.Password
value={customApiKey}
onChange={(event) => setCustomApiKey(event.target.value)}
placeholder="Enter API key"
placeholder="Enter Virtual Key"
className="w-56"
/>
)}

View file

@ -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";

View file

@ -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

View file

@ -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

View file

@ -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(),

View file

@ -297,7 +297,7 @@ export default function KeyInfoView({
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
{backButtonText}
</Button>
<Title>{currentKeyData.key_alias || "API Key"}</Title>
<Title>{currentKeyData.key_alias || "Virtual Key"}</Title>
<div className="flex items-center cursor-pointer mb-2 space-y-6">
<div>
@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@ -415,7 +415,7 @@ export default function KeyInfoView({
</div>
<div>
<p className="text-base font-medium text-red-600">
Warning: You are about to delete this API key.
Warning: You are about to delete this Virtual Key.
</p>
<p className="text-base text-red-600 mt-2">
This action is irreversible and will immediately revoke access for any applications using this
@ -423,7 +423,7 @@ export default function KeyInfoView({
</p>
</div>
</div>
<p className="text-base text-gray-600 mb-5">Are you sure you want to delete this API key?</p>
<p className="text-base text-gray-600 mb-5">Are you sure you want to delete this Virtual Key?</p>
<div className="mb-5">
<label className="block text-base font-medium text-gray-700 mb-2">
{`Type `}

View file

@ -273,7 +273,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
const handleRegenerateKey = async () => {
if (!premiumUser) {
NotificationManager.warning({
description: "Regenerate API Key is an Enterprise feature. Please upgrade to use this feature.",
description: "Regenerate Virtual Key is an Enterprise feature. Please upgrade to use this feature.",
});
return;
}
@ -298,10 +298,10 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
setRegenerateDialogVisible(false);
regenerateForm.resetFields();
NotificationManager.success({ description: "API Key regenerated successfully" });
NotificationManager.success({ description: "Virtual Key regenerated successfully" });
} catch (error) {
console.error("Error regenerating key:", error);
NotificationManager.error({ description: "Failed to regenerate API Key" });
NotificationManager.error({ description: "Failed to regenerate Virtual Key" });
}
};
@ -366,7 +366,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
</div>
<div>
<p className="text-base font-medium text-red-600">
Warning: You are about to delete this API key.
Warning: You are about to delete this Virtual Key.
</p>
<p className="text-base text-red-600 mt-2">
This action is irreversible and will immediately revoke access for any applications using this
@ -374,7 +374,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
</p>
</div>
</div>
<p className="text-base text-gray-600 mb-5">Are you sure you want to delete this API key?</p>
<p className="text-base text-gray-600 mb-5">Are you sure you want to delete this Virtual Key?</p>
<div className="mb-5">
<label className="block text-base font-medium text-gray-700 mb-2">
{`Type `}
@ -407,7 +407,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
disabled={!isValid}
className={`px-5 py-3 rounded-md text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 ${isValid ? "bg-red-600 hover:bg-red-700" : "bg-red-300 cursor-not-allowed"}`}
>
Delete Key
Delete Virtual Key
</button>
</div>
</div>
@ -417,7 +417,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
{/* Regenerate Key Form Modal */}
<Modal
title="Regenerate API Key"
title="Regenerate Virtual Key"
visible={regenerateDialogVisible}
onCancel={() => {
setRegenerateDialogVisible(false);
@ -516,7 +516,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
{selectedToken?.key_alias || "No alias set"}
</pre>
</div>
<Text className="mt-3">New API Key:</Text>
<Text className="mt-3">New Virtual Key:</Text>
<div
style={{
background: "#f8f8f8",
@ -529,9 +529,9 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
</div>
<CopyToClipboard
text={regeneratedKey}
onCopy={() => NotificationManager.success({ description: "API Key copied to clipboard" })}
onCopy={() => NotificationManager.success({ description: "Virtual Key copied to clipboard" })}
>
<Button className="mt-3">Copy API Key</Button>
<Button className="mt-3">Copy Virtual Key</Button>
</CopyToClipboard>
</Col>
</Grid>

View file

@ -615,7 +615,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
</Col>
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top API Keys</Title>
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={topKeys}
accessToken={accessToken}

View file

@ -46,6 +46,12 @@ export const columns = (
enableSorting: true,
cell: ({ row }) => <span className="text-xs">{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}</span>,
},
{
header: "User Alias",
accessorKey: "user_alias",
enableSorting: false,
cell: ({ row }) => <span className="text-xs">{row.original.user_alias || "-"}</span>,
},
{
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 }) => (
<Grid numItems={2}>
{row.original.key_count > 0 ? (
<Badge size="xs" color="indigo">
{row.original.key_count} Keys
{row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"}
</Badge>
) : (
<Badge size="xs" color="gray">

View file

@ -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<string, Record<string, string>> | 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(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
possibleUIRoles={null}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
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()}
/>,
);
render(<UserDataTable {...getDefaultProps()} />);
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(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
{...getDefaultProps()}
possibleUIRoles={possibleUIRoles}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
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()}
onSortChange={onSortChange}
currentSort={{ sortBy: filters.sort_by, sortOrder: filters.sort_order }}
/>,
@ -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(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
possibleUIRoles={null}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
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()}
isLoading={true}
/>,
);
render(<UserDataTable {...getDefaultProps()} isLoading={true} />);
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(
<UserDataTable
data={[]}
columns={[]}
accessToken={null}
userRole={"Admin"}
possibleUIRoles={null}
filters={filters}
updateFilters={updateFilters}
initialFilters={filters}
teams={[]}
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()}
isLoading={false}
/>,
);
render(<UserDataTable {...getDefaultProps()} isLoading={false} />);
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(<UserDataTable {...getDefaultProps()} possibleUIRoles={possibleUIRoles} />);
[
"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();
});
});
});

View file

@ -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;

View file

@ -320,9 +320,11 @@ export default function UserInfoView({
</Card>
<Card>
<Text>API Keys</Text>
<Text>Virtual Keys</Text>
<div className="mt-2">
<Text>{userData.keys?.length || 0} keys</Text>
<Text>
{userData.keys?.length || 0} {userData.keys?.length === 1 ? "Key" : "Keys"}
</Text>
</div>
</Card>
@ -467,7 +469,7 @@ export default function UserInfoView({
</div>
<div>
<Text className="font-medium">API Keys</Text>
<Text className="font-medium">Virtual Keys</Text>
<div className="flex flex-wrap gap-2 mt-1">
{userData.keys?.length && userData.keys?.length > 0 ? (
userData.keys.map((key, index) => (
@ -476,7 +478,7 @@ export default function UserInfoView({
</span>
))
) : (
<Text>No API keys</Text>
<Text>No Virtual Keys</Text>
)}
</div>
</div>