mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
Merge remote-tracking branch 'origin' into litellm_ui_cred_refresh
This commit is contained in:
commit
4006987f0e
38 changed files with 1574 additions and 295 deletions
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
<li>Download our CSV template</li>
|
||||
<li>Add your users' 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 `}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue