Prevent writing default user setting updates to yaml (error in non-root env) + Use central team member budget when max_budget_in_team set on UI (#12533)

* fix(proxy_setting_endpoints.py): require store model in db is enabled for setting user default settings

* test(test_proxy_server.py): update test

* fix(reset_budget_job.py): initial commit adding reset budget logic for team members

* test: update unit testing

* test(test_proxy_budget_reset.py): validate team member budget was reset

* test(test_reset_budget_job.py): update unit tests

* test: update tests
This commit is contained in:
Krish Dholakia
2025-07-12 10:13:07 -07:00
committed by GitHub
parent 6bc926e04b
commit d202ce229b
8 changed files with 441 additions and 100 deletions
+57 -20
View File
@@ -46,13 +46,35 @@ class ResetBudgetJob:
await self.reset_budget_for_litellm_teams()
### RESET ENDUSER (Customer) BUDGET and corresponding Budget duration ###
await self.reset_budget_for_litellm_endusers()
await self.reset_budget_for_litellm_budget_table()
async def reset_budget_for_litellm_endusers(self):
async def reset_budget_for_litellm_team_members(
self, budgets_to_reset: List[LiteLLM_BudgetTableFull]
):
"""
Resets the budget for all LiteLLM End-Users (Customers) if their budget has expired
Resets the budget for all LiteLLM Team Members if their budget has expired
"""
return await self.prisma_client.db.litellm_teammembership.update_many(
where={
"budget_id": {
"in": [
budget.budget_id
for budget in budgets_to_reset
if budget.budget_id is not None
]
}
},
data={
"spend": 0,
},
)
async def reset_budget_for_litellm_budget_table(self):
"""
Resets the budget for all LiteLLM End-Users (Customers), and Team Members if their budget has expired
The corresponding Budget duration is also updated.
"""
now = datetime.now(timezone.utc)
start_time = time.time()
endusers_to_reset: Optional[List[LiteLLM_EndUserTable]] = None
@@ -69,6 +91,7 @@ class ResetBudgetJob:
budget = await ResetBudgetJob._reset_budget_reset_at_date(
budget, now
)
await self.prisma_client.update_data(
query_type="update_many",
data_list=budgets_to_reset,
@@ -78,7 +101,15 @@ class ResetBudgetJob:
endusers_to_reset = await self.prisma_client.get_data(
table_name="enduser",
query_type="find_all",
budget_id_list=[budget.budget_id for budget in budgets_to_reset],
budget_id_list=[
budget.budget_id
for budget in budgets_to_reset
if budget.budget_id is not None
],
)
await self.reset_budget_for_litellm_team_members(
budgets_to_reset=budgets_to_reset
)
if endusers_to_reset is not None and len(endusers_to_reset) > 0:
@@ -125,19 +156,19 @@ class ResetBudgetJob:
self.proxy_logging_obj.service_logging_obj.async_service_success_hook(
service=ServiceTypes.RESET_BUDGET_JOB,
duration=end_time - start_time,
call_type="reset_budget_endusers",
call_type="reset_budget_budget_table",
start_time=start_time,
end_time=end_time,
event_metadata={
"num_budgets_found": len(budgets_to_reset)
if budgets_to_reset
else 0,
"num_budgets_found": (
len(budgets_to_reset) if budgets_to_reset else 0
),
"budgets_found": json.dumps(
budgets_to_reset, indent=4, default=str
),
"num_endusers_found": len(endusers_to_reset)
if endusers_to_reset
else 0,
"num_endusers_found": (
len(endusers_to_reset) if endusers_to_reset else 0
),
"endusers_found": json.dumps(
endusers_to_reset, indent=4, default=str
),
@@ -163,15 +194,15 @@ class ResetBudgetJob:
start_time=start_time,
end_time=end_time,
event_metadata={
"num_budgets_found": len(budgets_to_reset)
if budgets_to_reset
else 0,
"num_budgets_found": (
len(budgets_to_reset) if budgets_to_reset else 0
),
"budgets_found": json.dumps(
budgets_to_reset, indent=4, default=str
),
"num_endusers_found": len(endusers_to_reset)
if endusers_to_reset
else 0,
"num_endusers_found": (
len(endusers_to_reset) if endusers_to_reset else 0
),
"endusers_found": json.dumps(
endusers_to_reset, indent=4, default=str
),
@@ -465,7 +496,10 @@ class ResetBudgetJob:
item.spend = 0.0
if hasattr(item, "budget_duration") and item.budget_duration is not None:
# Get standardized reset time based on budget duration
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.timezone_utils import (
get_budget_reset_time,
)
item.budget_reset_at = get_budget_reset_time(
budget_duration=item.budget_duration
)
@@ -510,10 +544,13 @@ class ResetBudgetJob:
@staticmethod
async def _reset_budget_reset_at_date(
budget: LiteLLM_BudgetTableFull, current_time: datetime
) -> Optional[LiteLLM_BudgetTableFull]:
) -> LiteLLM_BudgetTableFull:
try:
if budget.budget_duration is not None:
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.duration_parser import (
duration_in_seconds,
)
duration_s = duration_in_seconds(duration=budget.budget_duration)
# Fallback for existing budgets that do not have a budget_reset_at date set, ensuring the duration is taken into account
@@ -282,7 +282,15 @@ async def _update_litellm_setting(
in_memory_var: The in-memory variable to update
success_message: Message to return on success
"""
from litellm.proxy.proxy_server import proxy_config
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
if store_model_in_db is not True:
raise HTTPException(
status_code=500,
detail={
"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."
},
)
# Update the in-memory settings
in_memory_var = settings.model_dump(exclude_none=True)
@@ -243,10 +243,19 @@ async def test_reset_budget_endusers_partial_failure():
enduser["spend"] = 0.0
return enduser
async def fake_reset_team_members(budgets_to_reset):
return 1
with patch.object(
ResetBudgetJob, "_reset_budget_for_enduser", side_effect=fake_reset_enduser
) as mock_reset_enduser:
await job.reset_budget_for_litellm_endusers()
ResetBudgetJob,
"_reset_budget_for_enduser",
side_effect=fake_reset_enduser,
) as mock_reset_enduser, patch.object(
ResetBudgetJob,
"reset_budget_for_litellm_team_members",
side_effect=fake_reset_team_members,
) as mock_reset_team_members:
await job.reset_budget_for_litellm_budget_table()
await asyncio.sleep(0.1)
assert mock_reset_enduser.call_count == 6
@@ -415,6 +424,9 @@ async def test_reset_budget_continues_other_categories_on_failure():
enduser["spend"] = 0.0
return enduser
async def fake_reset_team_members(budgets_to_reset):
return 1
with patch.object(
ResetBudgetJob, "_reset_budget_for_key", side_effect=fake_reset_key
) as mock_reset_key, patch.object(
@@ -423,7 +435,11 @@ async def test_reset_budget_continues_other_categories_on_failure():
ResetBudgetJob, "_reset_budget_for_team", side_effect=fake_reset_team
) as mock_reset_team, patch.object(
ResetBudgetJob, "_reset_budget_for_enduser", side_effect=fake_reset_enduser
) as mock_reset_enduser:
) as mock_reset_enduser, patch.object(
ResetBudgetJob,
"reset_budget_for_litellm_team_members",
side_effect=fake_reset_team_members,
) as mock_reset_team_members:
# Call the overall reset_budget method.
await job.reset_budget()
await asyncio.sleep(0.1)
@@ -432,7 +448,16 @@ async def test_reset_budget_continues_other_categories_on_failure():
called_tables = {
call.kwargs.get("table_name") for call in prisma_client.get_data.await_args_list
}
assert called_tables == {"key", "user", "team", "budget", "enduser"}
if mock_reset_team_members.call_count > 0:
called_tables.add("team_membership")
assert called_tables == {
"key",
"user",
"team",
"budget",
"enduser",
"team_membership",
}
# Verify that update_data was called three times (one per category, enduser update includes two)
assert prisma_client.update_data.await_count == 5
@@ -850,15 +875,22 @@ async def test_service_logger_endusers_success():
enduser["spend"] = 0.0
return enduser
async def fake_reset_team_members(budgets_to_reset):
return 1
with patch.object(
ResetBudgetJob,
"_reset_budget_for_enduser",
side_effect=fake_reset_enduser,
):
) as mock_reset_enduser, patch.object(
ResetBudgetJob,
"reset_budget_for_litellm_team_members",
side_effect=fake_reset_team_members,
) as mock_reset_team_members:
with patch(
"litellm.proxy.common_utils.reset_budget_job.verbose_proxy_logger.exception"
) as mock_verbose_exc:
await job.reset_budget_for_litellm_endusers()
await job.reset_budget_for_litellm_budget_table()
await asyncio.sleep(0.1)
mock_verbose_exc.assert_not_called()
@@ -876,7 +908,7 @@ async def test_service_logger_endusers_success():
@pytest.mark.asyncio
async def test_service_logger_users_failure():
async def test_service_logger_endusers_failure():
"""
Test that a failure during enduser reset calls the failure hook with appropriate metadata,
logs the exception, and does not call the success hook.
@@ -920,15 +952,22 @@ async def test_service_logger_users_failure():
enduser["spend"] = 0.0
return enduser
async def fake_reset_team_members(budgets_to_reset):
return 1
with patch.object(
ResetBudgetJob,
"_reset_budget_for_enduser",
side_effect=fake_reset_enduser,
):
) as mock_reset_enduser, patch.object(
ResetBudgetJob,
"reset_budget_for_litellm_team_members",
side_effect=fake_reset_team_members,
) as mock_reset_team_members:
with patch(
"litellm.proxy.common_utils.reset_budget_job.verbose_proxy_logger.exception"
) as mock_verbose_exc:
await job.reset_budget_for_litellm_endusers()
await job.reset_budget_for_litellm_budget_table()
await asyncio.sleep(0.1)
# Verify exception logging
assert mock_verbose_exc.call_count >= 1
@@ -949,3 +988,69 @@ async def test_service_logger_users_failure():
endusers_found_str = event_metadata.get("endusers_found", "")
assert "user1" in endusers_found_str
proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called()
@pytest.mark.asyncio
async def test_reset_budget_for_litellm_team_members_called():
"""
Test that when reset_budget_for_litellm_budget_table is called,
team members' budgets are also reset via reset_budget_for_litellm_team_members
"""
# Arrange
budget1 = LiteLLM_BudgetTableFull(
**{
"budget_id": "budget1",
"max_budget": 100.0,
"budget_duration": "1d",
"created_at": datetime.now(timezone.utc) - timedelta(days=2),
}
)
enduser1 = {"user_id": "user1", "spend": 25.0, "budget_id": "budget1"}
prisma_client = MagicMock()
async def fake_get_data(*, table_name, query_type, **kwargs):
if table_name == "budget":
return [budget1]
elif table_name == "enduser":
return [enduser1]
return []
prisma_client.get_data = AsyncMock(side_effect=fake_get_data)
prisma_client.update_data = AsyncMock()
# Mock the db.litellm_teammembership.update_many call
prisma_client.db = MagicMock()
prisma_client.db.litellm_teammembership = MagicMock()
prisma_client.db.litellm_teammembership.update_many = AsyncMock(
return_value={"count": 2}
)
proxy_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock()
proxy_logging_obj.service_logging_obj.async_service_failure_hook = AsyncMock()
job = ResetBudgetJob(proxy_logging_obj, prisma_client)
async def fake_reset_enduser(enduser):
enduser["spend"] = 0.0
return enduser
with patch.object(
ResetBudgetJob,
"_reset_budget_for_enduser",
side_effect=fake_reset_enduser,
):
# Act
await job.reset_budget_for_litellm_budget_table()
# Assert
# Verify that the team membership update was called
prisma_client.db.litellm_teammembership.update_many.assert_called_once()
# Verify the call was made with correct parameters
call_args = prisma_client.db.litellm_teammembership.update_many.call_args
assert call_args.kwargs["where"]["budget_id"]["in"] == ["budget1"]
assert call_args.kwargs["data"]["spend"] == 0
@@ -3,6 +3,7 @@ import os
import sys
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
import pytest
@@ -16,19 +17,70 @@ from litellm.proxy.utils import ProxyLogging
# Mock classes for testing
class MockLiteLLMTeamMembership:
async def update_many(
self, where: Dict[str, Any], data: Dict[str, Any]
) -> Dict[str, Any]:
# Mock the update_many method for litellm_teammembership
return {"count": 1}
class MockDB:
def __init__(self):
self.litellm_teammembership = MockLiteLLMTeamMembership()
class MockPrismaClient:
def __init__(self):
self.data = {"key": [], "user": [], "team": [], "budget": [], "enduser": []}
self.updated_data = {
self.data: Dict[str, List[Any]] = {
"key": [],
"user": [],
"team": [],
"budget": [],
"enduser": [],
}
self.updated_data: Dict[str, List[Any]] = {
"key": [],
"user": [],
"team": [],
"budget": [],
"enduser": [],
}
self.db = MockDB()
async def get_data(self, table_name, query_type, **kwargs):
return self.data.get(table_name, [])
data = self.data.get(table_name, [])
# Handle specific filtering for budget table queries
if table_name == "budget" and query_type == "find_all" and "reset_at" in kwargs:
# Return budgets that need to be reset (simulate expired budgets)
return [item for item in data if hasattr(item, "budget_reset_at")]
# Handle specific filtering for enduser table queries
if (
table_name == "enduser"
and query_type == "find_all"
and "budget_id_list" in kwargs
):
budget_id_list = kwargs["budget_id_list"]
# Return endusers that match the budget IDs
return [
item
for item in data
if hasattr(item, "litellm_budget_table")
and hasattr(item.litellm_budget_table, "budget_id")
and item.litellm_budget_table.budget_id in budget_id_list
]
# Handle key queries with expires and reset_at
if (
table_name == "key"
and query_type == "find_all"
and ("expires" in kwargs or "reset_at" in kwargs)
):
return [item for item in data if hasattr(item, "budget_reset_at")]
return data
async def update_data(self, query_type, data_list, table_name):
self.updated_data[table_name] = data_list
@@ -177,7 +229,7 @@ def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
mock_prisma_client.data["enduser"] = [test_enduser]
# Run the test
asyncio.run(reset_budget_job.reset_budget_for_litellm_endusers())
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
# Verify results
assert len(mock_prisma_client.updated_data["enduser"]) == 1
+168 -50
View File
@@ -617,8 +617,8 @@ async def test_get_config_from_file(tmp_path, monkeypatch):
async def test_add_proxy_budget_to_db_only_creates_user_no_keys():
"""
Test that _add_proxy_budget_to_db only creates a user and no keys are added.
This validates that generate_key_helper_fn is called with table_name="user"
This validates that generate_key_helper_fn is called with table_name="user"
which should prevent key creation in LiteLLM_VerificationToken table.
"""
from unittest.mock import AsyncMock, patch
@@ -629,31 +629,36 @@ async def test_add_proxy_budget_to_db_only_creates_user_no_keys():
# Set up required litellm settings
litellm.budget_duration = "30d"
litellm.max_budget = 100.0
litellm_proxy_budget_name = "litellm-proxy-budget"
# Mock generate_key_helper_fn to capture its call arguments
mock_generate_key_helper = AsyncMock(return_value={
"user_id": litellm_proxy_budget_name,
"max_budget": 100.0,
"budget_duration": "30d",
"spend": 0,
"models": [],
})
mock_generate_key_helper = AsyncMock(
return_value={
"user_id": litellm_proxy_budget_name,
"max_budget": 100.0,
"budget_duration": "30d",
"spend": 0,
"models": [],
}
)
# Patch generate_key_helper_fn in proxy_server where it's being called from
with patch("litellm.proxy.proxy_server.generate_key_helper_fn", mock_generate_key_helper):
with patch(
"litellm.proxy.proxy_server.generate_key_helper_fn", mock_generate_key_helper
):
# Call the function under test
ProxyStartupEvent._add_proxy_budget_to_db(litellm_proxy_budget_name)
# Allow async task to complete
import asyncio
await asyncio.sleep(0.1)
# Verify that generate_key_helper_fn was called
mock_generate_key_helper.assert_called_once()
call_args = mock_generate_key_helper.call_args
# Verify critical parameters that prevent key creation
assert call_args.kwargs["request_type"] == "user"
assert call_args.kwargs["table_name"] == "user"
@@ -682,38 +687,45 @@ async def test_custom_ui_sso_sign_in_handler_config_loading():
},
"model_list": [],
"router_settings": {},
"litellm_settings": {}
"litellm_settings": {},
}
# Create temporary config file
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(test_config, f)
config_file_path = f.name
# Mock the get_instance_fn to return a mock handler
mock_custom_handler = MagicMock()
try:
with patch("litellm.proxy.proxy_server.get_instance_fn", return_value=mock_custom_handler) as mock_get_instance:
with patch(
"litellm.proxy.proxy_server.get_instance_fn",
return_value=mock_custom_handler,
) as mock_get_instance:
# Create ProxyConfig instance and load config
proxy_config = ProxyConfig()
# Create a mock router since load_config requires it
mock_router = MagicMock()
await proxy_config.load_config(router=mock_router, config_file_path=config_file_path)
await proxy_config.load_config(
router=mock_router, config_file_path=config_file_path
)
# Verify get_instance_fn was called with correct parameters
mock_get_instance.assert_called_with(
value="custom_hooks.custom_ui_sso_hook.custom_ui_sso_sign_in_handler",
config_file_path=config_file_path
config_file_path=config_file_path,
)
# Verify the global variable was set
from litellm.proxy.proxy_server import user_custom_ui_sso_sign_in_handler
assert user_custom_ui_sso_sign_in_handler == mock_custom_handler
finally:
# Clean up temporary file
import os
os.unlink(config_file_path)
@@ -727,35 +739,41 @@ async def test_load_environment_variables_direct_and_os_environ():
from litellm.proxy.proxy_server import ProxyConfig
proxy_config = ProxyConfig()
# Test config with both direct values and os.environ/ prefixed values
test_config = {
"environment_variables": {
"DIRECT_VAR": "direct_value",
"NUMERIC_VAR": 12345,
"BOOL_VAR": True,
"SECRET_VAR": "os.environ/ACTUAL_SECRET_VAR"
"SECRET_VAR": "os.environ/ACTUAL_SECRET_VAR",
}
}
# Mock get_secret_str to return a resolved value
mock_secret_value = "resolved_secret_value"
with patch("litellm.proxy.proxy_server.get_secret_str", return_value=mock_secret_value) as mock_get_secret:
with patch.dict(os.environ, {}, clear=False): # Don't clear existing env vars, just track changes
with patch(
"litellm.proxy.proxy_server.get_secret_str", return_value=mock_secret_value
) as mock_get_secret:
with patch.dict(
os.environ, {}, clear=False
): # Don't clear existing env vars, just track changes
# Call the method under test
proxy_config._load_environment_variables(test_config)
# Verify direct environment variables were set correctly
assert os.environ["DIRECT_VAR"] == "direct_value"
assert os.environ["NUMERIC_VAR"] == "12345" # Should be converted to string
assert os.environ["BOOL_VAR"] == "True" # Should be converted to string
# Verify os.environ/ prefixed variable was resolved and set
assert os.environ["SECRET_VAR"] == mock_secret_value
# Verify get_secret_str was called with the correct value
mock_get_secret.assert_called_once_with(secret_name="os.environ/ACTUAL_SECRET_VAR")
mock_get_secret.assert_called_once_with(
secret_name="os.environ/ACTUAL_SECRET_VAR"
)
@pytest.mark.asyncio
@@ -768,56 +786,156 @@ async def test_load_environment_variables_litellm_license_and_edge_cases():
from litellm.proxy.proxy_server import ProxyConfig
proxy_config = ProxyConfig()
# Test Case 1: LITELLM_LICENSE in environment_variables
test_config_with_license = {
"environment_variables": {
"LITELLM_LICENSE": "test_license_key",
"OTHER_VAR": "other_value"
"OTHER_VAR": "other_value",
}
}
# Mock _license_check
mock_license_check = MagicMock()
mock_license_check.is_premium.return_value = True
with patch("litellm.proxy.proxy_server._license_check", mock_license_check):
with patch.dict(os.environ, {}, clear=False):
# Call the method under test
proxy_config._load_environment_variables(test_config_with_license)
# Verify LITELLM_LICENSE was set in environment
assert os.environ["LITELLM_LICENSE"] == "test_license_key"
# Verify license check was updated
assert mock_license_check.license_str == "test_license_key"
mock_license_check.is_premium.assert_called_once()
# Test Case 2: No environment_variables in config
test_config_no_env_vars = {}
# This should not raise any errors and should return without doing anything
result = proxy_config._load_environment_variables(test_config_no_env_vars)
assert result is None # Method returns None
# Test Case 3: environment_variables is None
test_config_none_env_vars = {"environment_variables": None}
# This should not raise any errors and should return without doing anything
result = proxy_config._load_environment_variables(test_config_none_env_vars)
assert result is None # Method returns None
# Test Case 4: os.environ/ prefix but get_secret_str returns None
test_config_secret_none = {
"environment_variables": {
"FAILED_SECRET": "os.environ/NONEXISTENT_SECRET"
}
"environment_variables": {"FAILED_SECRET": "os.environ/NONEXISTENT_SECRET"}
}
with patch("litellm.proxy.proxy_server.get_secret_str", return_value=None):
with patch.dict(os.environ, {}, clear=False):
# Call the method under test
proxy_config._load_environment_variables(test_config_secret_none)
# Verify that the environment variable was not set when secret resolution fails
assert "FAILED_SECRET" not in os.environ
@pytest.mark.asyncio
async def test_write_config_to_file(monkeypatch):
"""
Do not write config to file if store_model_in_db is True
"""
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
from litellm.proxy.proxy_server import ProxyConfig
# Set store_model_in_db to True
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
# Mock prisma_client to not be None (so DB path is taken)
mock_prisma_client = AsyncMock()
mock_prisma_client.insert_data = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Mock general_settings
mock_general_settings = {"store_model_in_db": True}
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings", mock_general_settings
)
# Mock user_config_file_path
test_config_path = "/tmp/test_config.yaml"
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_config_file_path", test_config_path
)
proxy_config = ProxyConfig()
# Mock the open function to track if file writing is attempted
mock_file_open = mock_open()
with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump:
# Call save_config with test data
test_config = {"key": "value", "model_list": ["model1", "model2"]}
await proxy_config.save_config(new_config=test_config)
# Verify that file was NOT opened for writing (since store_model_in_db=True)
mock_file_open.assert_not_called()
mock_yaml_dump.assert_not_called()
# Verify that database insert was called instead
mock_prisma_client.insert_data.assert_called_once()
# Verify the config passed to DB has model_list removed
call_args = mock_prisma_client.insert_data.call_args
assert call_args.kwargs["data"] == {
"key": "value"
} # model_list should be popped
assert call_args.kwargs["table_name"] == "config"
@pytest.mark.asyncio
async def test_write_config_to_file_when_store_model_in_db_false(monkeypatch):
"""
Test that config IS written to file when store_model_in_db is False
"""
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
from litellm.proxy.proxy_server import ProxyConfig
# Set store_model_in_db to False
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
# Mock prisma_client to be None (so file path is taken)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
# Mock general_settings
mock_general_settings = {"store_model_in_db": False}
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings", mock_general_settings
)
# Mock user_config_file_path
test_config_path = "/tmp/test_config.yaml"
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_config_file_path", test_config_path
)
proxy_config = ProxyConfig()
# Mock the open function and yaml.dump
mock_file_open = mock_open()
with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump:
# Call save_config with test data
test_config = {"key": "value", "other_key": "other_value"}
await proxy_config.save_config(new_config=test_config)
# Verify that file WAS opened for writing (since store_model_in_db=False)
mock_file_open.assert_called_once_with(f"{test_config_path}", "w")
# Verify yaml.dump was called with the config
mock_yaml_dump.assert_called_once_with(
test_config,
mock_file_open.return_value.__enter__.return_value,
default_flow_style=False,
)
@@ -11,7 +11,10 @@ sys.path.insert(
from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles
from litellm.proxy.proxy_server import app
from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams, SSOConfig
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
SSOConfig,
)
client = TestClient(app)
@@ -35,16 +38,14 @@ def mock_proxy_config(monkeypatch):
"rpm_limit": 10,
},
},
"general_settings": {
"proxy_admin_email": "admin@example.com"
},
"general_settings": {"proxy_admin_email": "admin@example.com"},
"environment_variables": {
"GOOGLE_CLIENT_ID": "test_google_client_id",
"GOOGLE_CLIENT_SECRET": "test_google_client_secret",
"MICROSOFT_CLIENT_ID": "test_microsoft_client_id",
"MICROSOFT_CLIENT_SECRET": "test_microsoft_client_secret",
"PROXY_BASE_URL": "https://example.com"
}
"PROXY_BASE_URL": "https://example.com",
},
}
async def mock_get_config():
@@ -118,8 +119,10 @@ class TestProxySettingEndpoints:
):
"""Test updating the internal user settings"""
# Mock litellm.default_internal_user_params
import litellm
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
monkeypatch.setattr(litellm, "default_internal_user_params", {})
# New settings to update
@@ -132,6 +135,7 @@ class TestProxySettingEndpoints:
response = client.patch("/update/internal_user_settings", json=new_settings)
print(response.text)
assert response.status_code == 200
data = response.json()
@@ -190,6 +194,7 @@ class TestProxySettingEndpoints:
# Mock litellm.default_team_params
import litellm
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
monkeypatch.setattr(litellm, "default_team_params", {})
# New settings to update
@@ -271,7 +276,7 @@ class TestProxySettingEndpoints:
"microsoft_client_id": "new_microsoft_client_id",
"microsoft_client_secret": "new_microsoft_client_secret",
"proxy_base_url": "https://newexample.com",
"user_email": "newadmin@example.com"
"user_email": "newadmin@example.com",
}
response = client.patch("/update/sso_settings", json=new_sso_settings)
@@ -286,17 +291,33 @@ class TestProxySettingEndpoints:
# Verify settings were updated
settings = data["settings"]
assert settings["google_client_id"] == new_sso_settings["google_client_id"]
assert settings["google_client_secret"] == new_sso_settings["google_client_secret"]
assert settings["microsoft_client_id"] == new_sso_settings["microsoft_client_id"]
assert settings["microsoft_client_secret"] == new_sso_settings["microsoft_client_secret"]
assert (
settings["google_client_secret"] == new_sso_settings["google_client_secret"]
)
assert (
settings["microsoft_client_id"] == new_sso_settings["microsoft_client_id"]
)
assert (
settings["microsoft_client_secret"]
== new_sso_settings["microsoft_client_secret"]
)
assert settings["proxy_base_url"] == new_sso_settings["proxy_base_url"]
assert settings["user_email"] == new_sso_settings["user_email"]
# Verify the config was updated
updated_config = mock_proxy_config["config"]
assert updated_config["environment_variables"]["GOOGLE_CLIENT_ID"] == new_sso_settings["google_client_id"]
assert updated_config["environment_variables"]["GOOGLE_CLIENT_SECRET"] == new_sso_settings["google_client_secret"]
assert updated_config["general_settings"]["proxy_admin_email"] == new_sso_settings["user_email"]
assert (
updated_config["environment_variables"]["GOOGLE_CLIENT_ID"]
== new_sso_settings["google_client_id"]
)
assert (
updated_config["environment_variables"]["GOOGLE_CLIENT_SECRET"]
== new_sso_settings["google_client_secret"]
)
assert (
updated_config["general_settings"]["proxy_admin_email"]
== new_sso_settings["user_email"]
)
# Verify save_config was called exactly once
assert mock_proxy_config["save_call_count"]() == 1
@@ -81,7 +81,7 @@ const SSOSettings: React.FC<SSOSettingsProps> = ({ accessToken, possibleUIRoles,
setIsEditing(false);
} catch (error) {
console.error("Error updating SSO settings:", error);
message.error("Failed to update settings");
message.error("Failed to update settings: " + error);
} finally {
setSaving(false);
}
@@ -4836,7 +4836,7 @@ export const updateInternalUserSettings = async (
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
throw new Error(errorData);
}
const data = await response.json();