mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 12:17:35 +00:00
* feat: multiple concurrent budget windows per API key and team (#24883) * feat(proxy): add BudgetLimitEntry type and wire budget_limits into key/team models * feat(schema): add budget_limits Json column to VerificationToken and TeamTable * feat(migrations): add migration for budget_limits column on keys and teams * feat(keys): initialize budget_limits windows with reset_at on key create/update * feat(teams): initialize budget_limits windows with reset_at on team create/update * feat(auth): add _virtual_key_multi_budget_check and _team_multi_budget_check * feat(auth): call multi-budget checks from common_checks for keys and teams * feat(proxy): increment per-window Redis spend counters after each request * feat(budget): reset individual budget windows on schedule via reset_budget_job * feat(ui): add hourly option to BudgetDurationDropdown * feat(ui): add budget_limits field to KeyResponse type * feat(ui): add Budget Windows editor to key edit view * feat(ui): add Budget Windows editor to create key form * fix(proxy): strip budget_limits=None before Prisma upsert to fix login 500 Prisma rejects nullable JSON fields (Json? without @default) when passed as Python None — it needs the field omitted entirely so the DB stores NULL via the column's nullable constraint. This was breaking /v2/login because the UI session key creation path hit the upsert with budget_limits=None. * ui(key-edit): use antd InputNumber+Button for budget windows, add reset hints * ui(create-key): use antd InputNumber+Button for budget windows, add reset hints * docs(users): add multiple budget windows section with API + dashboard walkthrough * fix: BudgetExceededError returns HTTP 429 instead of 400 - Add status_code=429 to BudgetExceededError class - auth_exception_handler hardcoded code=400 → code=429 * fix: no-op else branch in multi-budget auth checks causes KeyError - BudgetLimitEntry objects must be coerced via model_dump() not left as-is - Move _virtual_key_multi_budget_check into common_checks (was asymmetric with _team_multi_budget_check which already lived there) * fix: len() on JSON string returns char count not window count Guard with isinstance check + json.loads() before iterating per-window Redis counters in increment_spend_counters * fix: silent except:pass hides Redis reset failures in reset_budget_windows Log Redis counter reset failures as warnings so they are observable * test: add unit tests for multi-budget window enforcement 5 tests covering: no budget_limits passes, under budget passes, over hourly window raises 429, over monthly window raises 429, BudgetLimitEntry objects coerced without KeyError * fix: key per-window counters stable across reorders (duration key, not index) * fix: team+key per-window spend increments use duration key, not index * fix: budget window reset uses duration key; log failures instead of swallowing * refactor: extract BudgetWindowsEditor to shared component * refactor: key_edit_view imports BudgetWindowsEditor from shared component * refactor: create_key_button imports BudgetWindowsEditor from shared component --------- Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> * fix(reset_budget_job): extract _reset_expired_window helper to fix PLR0915 too many statements * feat(skills): Skills Registry & Hub — register skills, browse in AI Hub, public skill hub (#25118) * feat(skills): add domain and namespace fields to plugin types * feat(skills): store and return domain/namespace inside manifest_json * feat(skills): add /public/skill_hub endpoint for unauthenticated access * feat(skills): whitelist /public/skill_hub from auth requirements * feat(skills): add domain, namespace to Plugin and RegisterPluginRequest types * feat(skills): smart URL parser — paste github URL, auto-detect source type and name * feat(skills): replace enable toggle with Public badge, make rows clickable * feat(skills): add skill detail view with Overview and How to Use tabs * feat(skills): add MakeSkillPublicForm modal for publishing skills to the hub * feat(skills): rename panel to Skills, wire in skill detail view on row click * feat(skills): add skill hub table columns — name, description, domain, source, status * feat(skills): add SkillHubDashboard with stats row, domain dropdown filter, and table * feat(skills): add Skill Hub tab to AI Hub with Select Skills to Make Public button * feat(skills): move Skills to top-level nav item directly under MCP Servers * feat(skills): add skillHubPublicCall and NEXT_PUBLIC_BASE_URL support * feat(skills): add Skill Hub tab to public AI Hub page * feat(skills): add skills page routing in main app router * feat(skills): add /skills page route * chore: update package-lock after npm install * docs(skills): add Skills Gateway doc page with mermaid architecture diagram * docs(skills): add Skills Gateway to sidebar under Agent & MCP Gateway * docs(skills): add loom walkthrough video to Skills Gateway doc * chore: fixes --------- Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
138 lines
4.2 KiB
Python
138 lines
4.2 KiB
Python
"""
|
|
Unit tests for multi-budget-window enforcement on API keys.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.auth_checks import _virtual_key_multi_budget_check
|
|
|
|
|
|
def _make_valid_token(**kwargs) -> UserAPIKeyAuth:
|
|
defaults = dict(
|
|
token="sk-test-token",
|
|
key_name="test",
|
|
spend=0.0,
|
|
max_budget=None,
|
|
budget_limits=[],
|
|
)
|
|
defaults.update(kwargs)
|
|
return UserAPIKeyAuth(**defaults)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_budget_limits_passes():
|
|
"""Keys with empty budget_limits should pass without raising."""
|
|
token = _make_valid_token(budget_limits=[])
|
|
# Should not raise
|
|
await _virtual_key_multi_budget_check(valid_token=token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_under_budget_passes():
|
|
"""Key with spend under all windows should pass."""
|
|
token = _make_valid_token(
|
|
budget_limits=[
|
|
{"budget_duration": "24h", "max_budget": 10.0, "reset_at": None},
|
|
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
|
|
]
|
|
)
|
|
with patch(
|
|
"litellm.proxy.proxy_server.get_current_spend",
|
|
new_callable=AsyncMock,
|
|
return_value=1.0, # well under both windows
|
|
):
|
|
await _virtual_key_multi_budget_check(valid_token=token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_over_first_window_raises():
|
|
"""Key exceeding the first (daily) window should raise BudgetExceededError."""
|
|
token = _make_valid_token(
|
|
budget_limits=[
|
|
{"budget_duration": "24h", "max_budget": 5.0, "reset_at": None},
|
|
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
|
|
]
|
|
)
|
|
|
|
spend_by_window = [6.0, 6.0] # over daily, under monthly
|
|
|
|
call_count = 0
|
|
|
|
async def fake_get_spend(counter_key, fallback_spend):
|
|
nonlocal call_count
|
|
val = spend_by_window[call_count]
|
|
call_count += 1
|
|
return val
|
|
|
|
with patch(
|
|
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
|
|
):
|
|
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
|
await _virtual_key_multi_budget_check(valid_token=token)
|
|
|
|
err = exc_info.value
|
|
assert err.status_code == 429
|
|
assert "24h" in str(err)
|
|
assert "Key over" in str(err)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_over_second_window_raises():
|
|
"""Key exceeding only the monthly window should raise BudgetExceededError referencing 30d."""
|
|
token = _make_valid_token(
|
|
budget_limits=[
|
|
{"budget_duration": "24h", "max_budget": 50.0, "reset_at": None},
|
|
{"budget_duration": "30d", "max_budget": 5.0, "reset_at": None},
|
|
]
|
|
)
|
|
|
|
spend_by_window = [1.0, 10.0] # under daily, over monthly
|
|
|
|
call_count = 0
|
|
|
|
async def fake_get_spend(counter_key, fallback_spend):
|
|
nonlocal call_count
|
|
val = spend_by_window[call_count]
|
|
call_count += 1
|
|
return val
|
|
|
|
with patch(
|
|
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
|
|
):
|
|
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
|
await _virtual_key_multi_budget_check(valid_token=token)
|
|
|
|
err = exc_info.value
|
|
assert err.status_code == 429
|
|
assert "30d" in str(err)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_budget_limit_entry_objects_coerced():
|
|
"""BudgetLimitEntry Pydantic objects (not dicts) must be handled without KeyError.
|
|
|
|
While budget_limits is normally serialized as List[dict], the auth check must
|
|
tolerate BudgetLimitEntry objects in case they arrive without prior serialization.
|
|
"""
|
|
from litellm.proxy._types import BudgetLimitEntry
|
|
|
|
token = _make_valid_token(budget_limits=[])
|
|
# Bypass Pydantic validation to simulate BudgetLimitEntry objects reaching the check
|
|
object.__setattr__(
|
|
token,
|
|
"budget_limits",
|
|
[BudgetLimitEntry(budget_duration="24h", max_budget=10.0)],
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.proxy_server.get_current_spend",
|
|
new_callable=AsyncMock,
|
|
return_value=1.0,
|
|
):
|
|
# Should not raise TypeError / KeyError — model_dump() coerces the object
|
|
await _virtual_key_multi_budget_check(valid_token=token)
|