Files
litellm/tests/test_litellm/proxy/auth/test_multi_budget_windows.py
T
0afffe4366 feat: multiple concurrent budget windows per API key and team (#24883) (#25109)
* 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>
2026-04-06 14:02:04 -07:00

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)