mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 16:24:59 +00:00
Merge pull request #17707 from raghav-stripe/raghav-fix-responsesapi-rl
fix: responses api not applying tpm rate limits on api keys
This commit is contained in:
@@ -29,6 +29,7 @@ from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_utils import get_model_rate_limit_from_metadata
|
||||
from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
@@ -1232,6 +1233,28 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
||||
|
||||
return pipeline_operations
|
||||
|
||||
def _get_total_tokens_from_usage(self, usage: Any | None, rate_limit_type: Literal["output", "input", "total"]) -> int:
|
||||
# Get total tokens from response
|
||||
total_tokens = 0
|
||||
# spot fix for /responses api
|
||||
if usage:
|
||||
if isinstance(usage, Usage):
|
||||
if rate_limit_type == "output":
|
||||
total_tokens = usage.completion_tokens
|
||||
elif rate_limit_type == "input":
|
||||
total_tokens = usage.prompt_tokens
|
||||
elif rate_limit_type == "total":
|
||||
total_tokens = usage.total_tokens
|
||||
elif isinstance(usage, dict):
|
||||
# Responses API usage comes as a dict in ResponsesAPIResponse
|
||||
if rate_limit_type == "output":
|
||||
total_tokens = usage.get("completion_tokens", 0)
|
||||
elif rate_limit_type == "input":
|
||||
total_tokens = usage.get("prompt_tokens", 0)
|
||||
elif rate_limit_type == "total":
|
||||
total_tokens = usage.get("total_tokens", 0)
|
||||
return total_tokens
|
||||
|
||||
async def _execute_token_increment_script(
|
||||
self,
|
||||
pipeline_operations: List["RedisPipelineIncrementOperation"],
|
||||
@@ -1313,11 +1336,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
||||
|
||||
def get_rate_limit_type(self) -> Literal["output", "input", "total"]:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
specified_rate_limit_type = general_settings.get(
|
||||
"token_rate_limit_type", "output"
|
||||
"token_rate_limit_type", "total"
|
||||
)
|
||||
if not specified_rate_limit_type or specified_rate_limit_type not in [
|
||||
if specified_rate_limit_type not in [
|
||||
"output",
|
||||
"input",
|
||||
"total",
|
||||
@@ -1336,7 +1358,6 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
||||
get_model_group_from_litellm_kwargs,
|
||||
)
|
||||
from litellm.types.caching import RedisPipelineIncrementOperation
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
rate_limit_type = self.get_rate_limit_type()
|
||||
|
||||
@@ -1372,13 +1393,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
||||
response_obj, BaseLiteLLMOpenAIResponseObject
|
||||
):
|
||||
_usage = getattr(response_obj, "usage", None)
|
||||
if _usage and isinstance(_usage, Usage):
|
||||
if rate_limit_type == "output":
|
||||
total_tokens = _usage.completion_tokens
|
||||
elif rate_limit_type == "input":
|
||||
total_tokens = _usage.prompt_tokens
|
||||
elif rate_limit_type == "total":
|
||||
total_tokens = _usage.total_tokens
|
||||
total_tokens = self._get_total_tokens_from_usage(usage=_usage, rate_limit_type=rate_limit_type)
|
||||
|
||||
# Create pipeline operations for TPM increments
|
||||
pipeline_operations: List[RedisPipelineIncrementOperation] = []
|
||||
|
||||
@@ -1403,13 +1403,13 @@ async def test_async_log_success_event_increments_by_actual_tokens():
|
||||
end_time=None,
|
||||
)
|
||||
|
||||
# Verify increments happened with actual token count (50 completion tokens)
|
||||
# Verify increments happened with actual token count (60 total tokens)
|
||||
assert len(increment_calls) == 2, f"Expected 2 increment calls, got {len(increment_calls)}"
|
||||
|
||||
# Both should increment by 50 (completion_tokens, since rate_limit_type defaults to 'output')
|
||||
# Both should increment by 50 (total_tokens, since rate_limit_type defaults to 'total')
|
||||
for call in increment_calls:
|
||||
assert call["increment_value"] == 50, (
|
||||
f"Expected increment of 50 tokens, got {call['increment_value']} for key {call['key']}"
|
||||
assert call["increment_value"] == 60, (
|
||||
f"Expected increment of 60 tokens, got {call['increment_value']} for key {call['key']}"
|
||||
)
|
||||
|
||||
# Verify correct keys were used
|
||||
|
||||
@@ -1583,6 +1583,231 @@ async def test_missing_descriptor_fallback():
|
||||
assert "Current limit: 2" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rate_limit_type_default_is_total(monkeypatch):
|
||||
"""
|
||||
Test that get_rate_limit_type returns 'total' as the default when no setting is specified.
|
||||
|
||||
This verifies the change from 'output' to 'total' as the default value.
|
||||
"""
|
||||
local_cache = DualCache()
|
||||
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
|
||||
internal_usage_cache=InternalUsageCache(local_cache)
|
||||
)
|
||||
|
||||
# Mock general_settings to return empty dict (no token_rate_limit_type set)
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
original_settings = getattr(proxy_server, 'general_settings', {})
|
||||
monkeypatch.setattr(proxy_server, 'general_settings', {})
|
||||
|
||||
try:
|
||||
result = parallel_request_handler.get_rate_limit_type()
|
||||
assert result == "total", f"Default rate limit type should be 'total', got '{result}'"
|
||||
finally:
|
||||
monkeypatch.setattr(proxy_server, 'general_settings', original_settings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rate_limit_type_invalid_falls_back_to_total(monkeypatch):
|
||||
"""
|
||||
Test that get_rate_limit_type falls back to 'total' when an invalid value is specified.
|
||||
"""
|
||||
local_cache = DualCache()
|
||||
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
|
||||
internal_usage_cache=InternalUsageCache(local_cache)
|
||||
)
|
||||
|
||||
# Mock general_settings to return an invalid token_rate_limit_type
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
original_settings = getattr(proxy_server, 'general_settings', {})
|
||||
monkeypatch.setattr(proxy_server, 'general_settings', {'token_rate_limit_type': 'invalid_type'})
|
||||
|
||||
try:
|
||||
result = parallel_request_handler.get_rate_limit_type()
|
||||
assert result == "total", f"Invalid rate limit type should fall back to 'total', got '{result}'"
|
||||
finally:
|
||||
monkeypatch.setattr(proxy_server, 'general_settings', original_settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token_rate_limit_type,expected_field",
|
||||
[
|
||||
("input", "prompt_tokens"),
|
||||
("output", "completion_tokens"),
|
||||
("total", "total_tokens"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_log_success_event_with_dict_usage(monkeypatch, token_rate_limit_type, expected_field):
|
||||
"""
|
||||
Test that async_log_success_event correctly handles usage as a dict (Responses API format).
|
||||
|
||||
The Responses API returns usage as a dict in ResponsesAPIResponse instead of a Usage object.
|
||||
This test verifies that token counting works correctly with dict-based usage.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_api_key = "sk-12345"
|
||||
_api_key = hash_token(_api_key)
|
||||
local_cache = DualCache()
|
||||
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
|
||||
internal_usage_cache=InternalUsageCache(local_cache)
|
||||
)
|
||||
|
||||
# Mock the get_rate_limit_type method
|
||||
def mock_get_rate_limit_type():
|
||||
return token_rate_limit_type
|
||||
|
||||
monkeypatch.setattr(
|
||||
parallel_request_handler, "get_rate_limit_type", mock_get_rate_limit_type
|
||||
)
|
||||
|
||||
# Create a mock response object with usage as a dict (Responses API format)
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 35,
|
||||
"total_tokens": 60
|
||||
}
|
||||
# Make isinstance check for BaseLiteLLMOpenAIResponseObject return True
|
||||
from litellm.types.utils import BaseLiteLLMOpenAIResponseObject
|
||||
mock_response.__class__ = type('MockResponse', (BaseLiteLLMOpenAIResponseObject,), {})
|
||||
|
||||
# Create mock kwargs for the success event
|
||||
mock_kwargs = {
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_hash": _api_key,
|
||||
"user_api_key_user_id": None,
|
||||
"user_api_key_team_id": None,
|
||||
"user_api_key_end_user_id": None,
|
||||
}
|
||||
},
|
||||
"model": "gpt-3.5-turbo",
|
||||
}
|
||||
|
||||
# Mock the pipeline increment method to capture the operations
|
||||
captured_operations = []
|
||||
|
||||
async def mock_increment_pipeline(increment_list, **kwargs):
|
||||
captured_operations.extend(increment_list)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
parallel_request_handler.internal_usage_cache.dual_cache,
|
||||
"async_increment_cache_pipeline",
|
||||
mock_increment_pipeline,
|
||||
)
|
||||
|
||||
# Call the success event handler
|
||||
await parallel_request_handler.async_log_success_event(
|
||||
kwargs=mock_kwargs,
|
||||
response_obj=mock_response,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
# Find the TPM increment operation
|
||||
tpm_operation = None
|
||||
for op in captured_operations:
|
||||
if op["key"].endswith(":tokens"):
|
||||
tpm_operation = op
|
||||
break
|
||||
|
||||
assert tpm_operation is not None, "Should have a TPM increment operation"
|
||||
|
||||
# Check that the correct token count was used based on the rate limit type
|
||||
expected_tokens = {
|
||||
"input": 25, # prompt_tokens
|
||||
"output": 35, # completion_tokens
|
||||
"total": 60, # total_tokens
|
||||
}
|
||||
|
||||
assert (
|
||||
tpm_operation["increment_value"] == expected_tokens[token_rate_limit_type]
|
||||
), f"Expected {expected_tokens[token_rate_limit_type]} tokens for type '{token_rate_limit_type}', got {tpm_operation['increment_value']}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_log_success_event_with_dict_usage_missing_fields(monkeypatch):
|
||||
"""
|
||||
Test that async_log_success_event handles dict usage with missing fields gracefully.
|
||||
|
||||
When usage dict is missing expected fields, it should default to 0.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_api_key = "sk-12345"
|
||||
_api_key = hash_token(_api_key)
|
||||
local_cache = DualCache()
|
||||
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
|
||||
internal_usage_cache=InternalUsageCache(local_cache)
|
||||
)
|
||||
|
||||
# Mock the get_rate_limit_type method
|
||||
def mock_get_rate_limit_type():
|
||||
return "output"
|
||||
|
||||
monkeypatch.setattr(
|
||||
parallel_request_handler, "get_rate_limit_type", mock_get_rate_limit_type
|
||||
)
|
||||
|
||||
# Create a mock response object with usage as a dict missing some fields
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage = {
|
||||
"prompt_tokens": 25,
|
||||
# completion_tokens is missing
|
||||
# total_tokens is missing
|
||||
}
|
||||
from litellm.types.utils import BaseLiteLLMOpenAIResponseObject
|
||||
mock_response.__class__ = type('MockResponse', (BaseLiteLLMOpenAIResponseObject,), {})
|
||||
|
||||
# Create mock kwargs for the success event
|
||||
mock_kwargs = {
|
||||
"standard_logging_object": {
|
||||
"metadata": {
|
||||
"user_api_key_hash": _api_key,
|
||||
"user_api_key_user_id": None,
|
||||
"user_api_key_team_id": None,
|
||||
"user_api_key_end_user_id": None,
|
||||
}
|
||||
},
|
||||
"model": "gpt-3.5-turbo",
|
||||
}
|
||||
|
||||
# Mock the pipeline increment method to capture the operations
|
||||
captured_operations = []
|
||||
|
||||
async def mock_increment_pipeline(increment_list, **kwargs):
|
||||
captured_operations.extend(increment_list)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
parallel_request_handler.internal_usage_cache.dual_cache,
|
||||
"async_increment_cache_pipeline",
|
||||
mock_increment_pipeline,
|
||||
)
|
||||
|
||||
# Call the success event handler - should not raise exception
|
||||
await parallel_request_handler.async_log_success_event(
|
||||
kwargs=mock_kwargs,
|
||||
response_obj=mock_response,
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
# Find the TPM increment operation
|
||||
tpm_operation = None
|
||||
for op in captured_operations:
|
||||
if op["key"].endswith(":tokens"):
|
||||
tpm_operation = op
|
||||
break
|
||||
|
||||
assert tpm_operation is not None, "Should have a TPM increment operation"
|
||||
# Should default to 0 when field is missing
|
||||
assert tpm_operation["increment_value"] == 0, "Should default to 0 when completion_tokens is missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_token_increment_script_cluster_compatibility():
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user