mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 04:17:43 +00:00
* feat: enforce x-litellm-trace-id in header, if required * feat: update spend for agent * refactor: update agent table to follow similar format as other entities - also add a spend column - allows us to see spend of an agent * fix: cleanup ui * feat: return spend on agent endpoints * feat: scope pr * feat(agents/): support budgets + rate limiting on agents + agent sessions * fix: address PR review feedback - Add missing tpm_limit, rpm_limit, session_tpm_limit, session_rpm_limit columns to root schema.prisma to match proxy and extras schemas - Add backwards-compatible fallback to key metadata for max_iterations so existing users don't silently lose enforcement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: qa'ed RPM limiting on agents --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
5.1 KiB
Python
157 lines
5.1 KiB
Python
"""
|
|
Unit Tests for the max iterations limiter for the proxy.
|
|
|
|
Tests that session-scoped iteration counting works correctly:
|
|
- Enforces max_iterations per session_id (read from agent litellm_params)
|
|
- Different sessions have independent counters
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.max_iterations_limiter import _PROXY_MaxIterationsHandler
|
|
from litellm.proxy.utils import InternalUsageCache
|
|
from litellm.types.agents import AgentResponse
|
|
|
|
|
|
def _make_mock_agent(max_iterations: int) -> AgentResponse:
|
|
return AgentResponse(
|
|
agent_id="agent-test-123",
|
|
agent_name="test-agent",
|
|
litellm_params={"max_iterations": max_iterations},
|
|
agent_card_params={"name": "test-agent", "version": "1.0.0"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_iterations_basic_enforcement():
|
|
"""
|
|
Test that max_iterations is enforced per session_id.
|
|
|
|
- 3 requests with the same session_id should succeed when max_iterations=3
|
|
- 4th request should raise 429
|
|
"""
|
|
local_cache = DualCache()
|
|
handler = _PROXY_MaxIterationsHandler(
|
|
internal_usage_cache=InternalUsageCache(local_cache),
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-key-1234",
|
|
agent_id="agent-test-123",
|
|
)
|
|
|
|
mock_agent = _make_mock_agent(max_iterations=3)
|
|
|
|
with patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
|
|
) as mock_registry:
|
|
mock_registry.get_agent_by_id.return_value = mock_agent
|
|
|
|
# First 3 requests should succeed
|
|
for i in range(3):
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-abc"}},
|
|
call_type="",
|
|
)
|
|
|
|
# 4th request should fail with 429
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-abc"}},
|
|
call_type="",
|
|
)
|
|
assert exc_info.value.status_code == 429
|
|
assert "max_iterations" in str(exc_info.value.detail).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_iterations_different_sessions_independent():
|
|
"""
|
|
Test that different session_ids have independent iteration counters.
|
|
|
|
- Session A and Session B each get their own max_iterations budget
|
|
- Exhausting Session A does not affect Session B
|
|
"""
|
|
local_cache = DualCache()
|
|
handler = _PROXY_MaxIterationsHandler(
|
|
internal_usage_cache=InternalUsageCache(local_cache),
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-key-5678",
|
|
agent_id="agent-test-123",
|
|
)
|
|
|
|
mock_agent = _make_mock_agent(max_iterations=2)
|
|
|
|
with patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
|
|
) as mock_registry:
|
|
mock_registry.get_agent_by_id.return_value = mock_agent
|
|
|
|
# Session A: 2 calls succeed
|
|
for _ in range(2):
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-A"}},
|
|
call_type="",
|
|
)
|
|
|
|
# Session B: 2 calls succeed (independent counter)
|
|
for _ in range(2):
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-B"}},
|
|
call_type="",
|
|
)
|
|
|
|
# Session A: 3rd call fails
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-A"}},
|
|
call_type="",
|
|
)
|
|
assert exc_info.value.status_code == 429
|
|
|
|
# Session B: 3rd call also fails
|
|
with pytest.raises(HTTPException):
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-B"}},
|
|
call_type="",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_iterations_no_agent_id_passes():
|
|
"""
|
|
When no agent_id is set on the key, all requests pass through.
|
|
"""
|
|
local_cache = DualCache()
|
|
handler = _PROXY_MaxIterationsHandler(
|
|
internal_usage_cache=InternalUsageCache(local_cache),
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-key-no-agent",
|
|
)
|
|
|
|
result = await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=local_cache,
|
|
data={"metadata": {"session_id": "session-any"}},
|
|
call_type="",
|
|
)
|
|
assert result is None
|