fix: reset team member budgets based on team's budget_duration

Fixes #19105

## Problem
Team member budgets (max_budget_in_team) were not resetting properly:
1. Team members use litellm_budget_table for budget limits
2. Budget resets only occurred if litellm_budget_table had budget_duration set
3. When creating team members with max_budget_in_team, budget_duration wasn't inherited
4. Without budget_duration, team member budgets never reset, even though team budgets did

## Root Cause
The reset_budget_for_litellm_budget_table() method only reset budgets that had
their own budget_duration and reset_at time. Team members without explicit
budget_duration were never included in the reset.

## Solution
Added reset_budget_for_team_members_by_team() method that:
- Runs during the budget reset job
- Finds teams that need budget reset (based on team's budget_duration)
- Resets spend for ALL team members in those teams
- Ensures team member budgets inherit the team's reset schedule

## Changes
- litellm/proxy/common_utils/reset_budget_job.py:
  - Added reset_budget_for_team_members_by_team() method
  - Called in reset_budget() after resetting team budgets
- Added comprehensive tests to verify the fix
- Added reproduction script for issue validation

## Testing
- Unit tests verify team member budgets reset with team
- Tested with multiple teams and edge cases
- All existing tests pass

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Julio Quinteros Pro 2026-02-13 06:44:05 -03:00
parent 9a2410be71
commit 7c322f9946
3 changed files with 435 additions and 0 deletions

View file

@ -45,6 +45,9 @@ class ResetBudgetJob:
## Reset Team Budget
await self.reset_budget_for_litellm_teams()
### RESET TEAM MEMBER BUDGET (based on team budget duration) ###
await self.reset_budget_for_team_members_by_team()
### RESET ENDUSER (Customer) BUDGET and corresponding Budget duration ###
await self.reset_budget_for_litellm_budget_table()
@ -481,6 +484,85 @@ class ResetBudgetJob:
)
verbose_proxy_logger.exception("Failed to reset budget for teams: %s", e)
async def reset_budget_for_team_members_by_team(self):
"""
Resets the budget for team members based on their team's budget_duration.
This fixes issue #19105 where team member budgets don't reset because they
don't have their own budget_duration set - they should inherit from the team.
"""
now = datetime.utcnow()
start_time = time.time()
teams_to_process: Optional[List[LiteLLM_TeamTable]] = None
try:
# Get all teams that need budget reset
teams_to_process = await self.prisma_client.get_data(
table_name="team", query_type="find_all", reset_at=now
)
if teams_to_process is not None and len(teams_to_process) > 0:
team_ids_to_reset = [
team.team_id
for team in teams_to_process
if team.team_id is not None
]
verbose_proxy_logger.debug(
"Resetting team member budgets for teams: %s", team_ids_to_reset
)
# Reset spend for all team members in these teams
result = await self.prisma_client.db.litellm_teammembership.update_many(
where={
"team_id": {"in": team_ids_to_reset}
},
data={
"spend": 0,
},
)
verbose_proxy_logger.debug(
"Reset %s team member budgets", result
)
end_time = time.time()
asyncio.create_task(
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_team_members_by_team",
start_time=start_time,
end_time=end_time,
event_metadata={
"num_teams_found": len(teams_to_process) if teams_to_process else 0,
"teams_found": json.dumps(
teams_to_process, indent=4, default=str
),
},
)
)
except Exception as e:
end_time = time.time()
asyncio.create_task(
self.proxy_logging_obj.service_logging_obj.async_service_failure_hook(
service=ServiceTypes.RESET_BUDGET_JOB,
duration=end_time - start_time,
error=e,
call_type="reset_budget_team_members_by_team",
start_time=start_time,
end_time=end_time,
event_metadata={
"num_teams_found": len(teams_to_process) if teams_to_process else 0,
"teams_found": json.dumps(
teams_to_process, indent=4, default=str
),
},
)
)
verbose_proxy_logger.exception(
"Failed to reset budget for team members by team: %s", e
)
@staticmethod
async def _reset_budget_common(
item: Union[LiteLLM_TeamTable, LiteLLM_UserTable, LiteLLM_VerificationToken],

178
reproduce_issue_19105.py Normal file
View file

@ -0,0 +1,178 @@
"""
Reproduction script for issue #19105: Team member budgets not enforced
This script demonstrates the bug where:
1. Team member budget (max_budget_in_team) is not enforced - requests go through even when exceeded
2. Team member spend doesn't reset daily while team spend does
Setup:
- Create a team with max_budget and budget_duration
- Add a key to the team
- Add a user with max_budget_in_team
- Make requests to exceed the user's budget
- Verify requests still go through (BUG)
- Trigger budget reset and verify team member spend doesn't reset (BUG)
"""
import asyncio
import os
import sys
from datetime import datetime, timedelta, timezone
# Add litellm to path
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
async def reproduce_issue():
print("=" * 80)
print("REPRODUCING ISSUE #19105: Team Member Budget Not Enforced")
print("=" * 80)
# Initialize Prisma client
prisma_client = PrismaClient()
await prisma_client.connect()
try:
# Step 1: Create a team with budget and budget_duration
print("\n1. Creating team with budget...")
team = await prisma_client.db.litellm_teamtable.create(
data={
"team_alias": "test_team_19105",
"max_budget": 1.0,
"budget_duration": "1d", # Daily reset
"spend": 0.0,
}
)
print(f" Created team: {team.team_id}, budget: ${team.max_budget}, duration: {team.budget_duration}")
# Step 2: Create a key for the team
print("\n2. Creating key for team...")
key = await prisma_client.db.litellm_verificationtoken.create(
data={
"token": f"sk-test-{team.team_id}",
"team_id": team.team_id,
}
)
print(f" Created key: {key.token}")
# Step 3: Create a budget table for team member
print("\n3. Creating budget table for team member...")
budget = await prisma_client.db.litellm_budgettable.create(
data={
"max_budget": 0.01, # Very low budget to trigger limit
# Note: NOT setting budget_duration - this is the bug!
}
)
print(f" Created budget: budget_id={budget.budget_id}, max_budget=${budget.max_budget}")
print(f" ⚠️ Budget duration: {budget.budget_duration} (None - this is the problem!)")
# Step 4: Create a user (internal user)
print("\n4. Creating internal user...")
user = await prisma_client.db.litellm_usertable.create(
data={
"user_id": "test_user_19105",
"user_email": "test@example.com",
}
)
print(f" Created user: {user.user_id}")
# Step 5: Create team membership linking user to team with budget
print("\n5. Creating team membership with max_budget_in_team...")
membership = await prisma_client.db.litellm_teammembership.create(
data={
"team_id": team.team_id,
"user_id": user.user_id,
"budget_id": budget.budget_id,
"spend": 0.015, # Already over budget!
}
)
print(f" Created membership: user={user.user_id}, team={team.team_id}")
print(f" Member spend: ${membership.spend}, Member budget: ${budget.max_budget}")
print(f" ⚠️ Spend ${membership.spend} > Budget ${budget.max_budget} - should be blocked!")
# Step 6: Check if budget enforcement would work
print("\n6. Testing budget check logic...")
membership_with_budget = await prisma_client.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": user.user_id, "team_id": team.team_id}},
include={"litellm_budget_table": True},
)
if membership_with_budget and membership_with_budget.litellm_budget_table:
team_member_budget = membership_with_budget.litellm_budget_table.max_budget
team_member_spend = membership_with_budget.spend or 0.0
print(f" Spend: ${team_member_spend}, Budget: ${team_member_budget}")
if team_member_spend >= team_member_budget:
print(f" ✓ Budget check WOULD block request (spend >= budget)")
else:
print(f" ✗ Budget check would NOT block request")
# Step 7: Test budget reset logic
print("\n7. Testing budget reset logic...")
print(f" Current team member spend: ${membership.spend}")
print(f" Budget reset_at: {budget.budget_reset_at}")
# Create ProxyLogging object (needed for ResetBudgetJob)
proxy_logging_obj = ProxyLogging(user_api_key_cache=None)
reset_job = ResetBudgetJob(
proxy_logging_obj=proxy_logging_obj,
prisma_client=prisma_client,
)
# Manually set budget reset_at to past to trigger reset
now = datetime.now(timezone.utc)
past_time = now - timedelta(hours=1)
await prisma_client.db.litellm_budgettable.update(
where={"budget_id": budget.budget_id},
data={"budget_reset_at": past_time},
)
print(f" Set budget reset_at to: {past_time} (in the past)")
# Run budget reset
print(f"\n8. Running budget reset job...")
await reset_job.reset_budget()
# Check if team member spend was reset
membership_after = await prisma_client.db.litellm_teammembership.find_unique(
where={"user_id_team_id": {"user_id": user.user_id, "team_id": team.team_id}},
)
print(f" Team member spend after reset: ${membership_after.spend}")
if membership_after.spend == 0.0:
print(f" ✓ Team member spend WAS reset")
else:
print(f" ✗ BUG: Team member spend NOT reset (still ${membership_after.spend})")
print(f" This is because the budget table doesn't have a budget_duration set!")
# Clean up
print("\n9. Cleaning up...")
await prisma_client.db.litellm_teammembership.delete(
where={"user_id_team_id": {"user_id": user.user_id, "team_id": team.team_id}}
)
await prisma_client.db.litellm_usertable.delete(where={"user_id": user.user_id})
await prisma_client.db.litellm_verificationtoken.delete(where={"token": key.token})
await prisma_client.db.litellm_budgettable.delete(where={"budget_id": budget.budget_id})
await prisma_client.db.litellm_teamtable.delete(where={"team_id": team.team_id})
print(" Cleanup complete")
print("\n" + "=" * 80)
print("CONCLUSION:")
print("=" * 80)
print("Team member budgets don't reset because:")
print("1. Team members use litellm_budget_table for budget limits")
print("2. Budget resets only happen if litellm_budget_table has budget_duration set")
print("3. When creating team members with max_budget_in_team, the budget_duration")
print(" is NOT automatically inherited from the team")
print("4. Without budget_duration, the budget never resets")
print("\nFIX: Team member budgets should inherit budget_duration from their team")
print("=" * 80)
finally:
await prisma_client.disconnect()
if __name__ == "__main__":
asyncio.run(reproduce_issue())

View file

@ -0,0 +1,175 @@
"""
Test for issue #19105: Team member budget reset
Tests that team member budgets reset based on the team's budget_duration,
not just their individual budget table's duration.
"""
import asyncio
import pytest
import sys
import os
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
# Add project root to path
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import litellm
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
@pytest.mark.asyncio
async def test_team_member_budget_resets_with_team():
"""
Test that team member budgets reset when the team's budget resets,
even if the member's budget table doesn't have its own budget_duration.
This tests the fix for issue #19105.
"""
# Mock Prisma client
prisma_client = MagicMock(spec=PrismaClient)
prisma_client.db = MagicMock()
# Mock ProxyLogging
proxy_logging_obj = MagicMock(spec=ProxyLogging)
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()
# Create test data
now = datetime.utcnow()
past_time = now - timedelta(hours=1)
# Team with budget_duration that needs reset
team = MagicMock()
team.team_id = "team_123"
team.max_budget = 10.0
team.budget_duration = "1d"
team.budget_reset_at = past_time # In the past, needs reset
team.spend = 5.0
# Mock get_data to return the team
async def mock_get_data(table_name, query_type, **kwargs):
if table_name == "team" and "reset_at" in kwargs:
return [team]
return None
prisma_client.get_data = AsyncMock(side_effect=mock_get_data)
# Mock update_many for team membership
update_result = MagicMock()
update_result.count = 2 # 2 team members updated
prisma_client.db.litellm_teammembership.update_many = AsyncMock(
return_value=update_result
)
# Create reset job
reset_job = ResetBudgetJob(
proxy_logging_obj=proxy_logging_obj, prisma_client=prisma_client
)
# Run the team member reset
await reset_job.reset_budget_for_team_members_by_team()
# Verify that update_many was called with correct parameters
prisma_client.db.litellm_teammembership.update_many.assert_called_once()
call_args = prisma_client.db.litellm_teammembership.update_many.call_args
# Check the where clause includes the team_id
assert call_args.kwargs["where"]["team_id"] == {"in": ["team_123"]}
# Check that spend is reset to 0
assert call_args.kwargs["data"]["spend"] == 0
# Verify success hook was called
proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_called_once()
@pytest.mark.asyncio
async def test_team_member_budget_reset_no_teams():
"""
Test that the function handles the case where no teams need resetting.
"""
# Mock Prisma client
prisma_client = MagicMock(spec=PrismaClient)
prisma_client.db = MagicMock()
# Mock ProxyLogging
proxy_logging_obj = MagicMock(spec=ProxyLogging)
proxy_logging_obj.service_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock()
# Mock get_data to return empty list (no teams need reset)
prisma_client.get_data = AsyncMock(return_value=[])
# Mock update_many
prisma_client.db.litellm_teammembership.update_many = AsyncMock()
# Create reset job
reset_job = ResetBudgetJob(
proxy_logging_obj=proxy_logging_obj, prisma_client=prisma_client
)
# Run the team member reset
await reset_job.reset_budget_for_team_members_by_team()
# Verify that update_many was NOT called (no teams to process)
prisma_client.db.litellm_teammembership.update_many.assert_not_called()
@pytest.mark.asyncio
async def test_team_member_budget_reset_multiple_teams():
"""
Test that team member budgets are reset for multiple teams.
"""
# Mock Prisma client
prisma_client = MagicMock(spec=PrismaClient)
prisma_client.db = MagicMock()
# Mock ProxyLogging
proxy_logging_obj = MagicMock(spec=ProxyLogging)
proxy_logging_obj.service_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock()
# Create test data - multiple teams
now = datetime.utcnow()
past_time = now - timedelta(hours=1)
teams = []
for i in range(3):
team = MagicMock()
team.team_id = f"team_{i}"
team.max_budget = 10.0
team.budget_duration = "1d"
team.budget_reset_at = past_time
teams.append(team)
# Mock get_data to return multiple teams
prisma_client.get_data = AsyncMock(return_value=teams)
# Mock update_many
update_result = MagicMock()
update_result.count = 5 # 5 team members updated across all teams
prisma_client.db.litellm_teammembership.update_many = AsyncMock(
return_value=update_result
)
# Create reset job
reset_job = ResetBudgetJob(
proxy_logging_obj=proxy_logging_obj, prisma_client=prisma_client
)
# Run the team member reset
await reset_job.reset_budget_for_team_members_by_team()
# Verify that update_many was called with all team IDs
call_args = prisma_client.db.litellm_teammembership.update_many.call_args
assert set(call_args.kwargs["where"]["team_id"]["in"]) == {
"team_0",
"team_1",
"team_2",
}