mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rename fork-flag to unit-flag now that it applies on every event * test: move tests/test_litellm root and small trees into tests/unit Pure renames, no content changes. Follow-up commits in this PR fix references, merge the three files that already existed in tests/unit, keep live-provider tests in tests/test_litellm and wire CI. * test: carry tests/test_litellm conftest isolation into tests/unit Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS, proxy-URL and keychain env, and session-end client cleanup now reset for unit tests too. The environment isolation owns its MonkeyPatch so a test's own monkeypatch is undone before the model-cost teardown runs. * test: merge, split and prune the moved root and small-tree tests Merge batches/test_batch_utils.py and the chat_completions and messages dispatch tests into the files that already existed in tests/unit. Keep the live Gemini interactions tests, the async image-fetch format test and the OpenAI embedding scorer test in tests/test_litellm since they need real network or keys. Put test_router.py under tests/unit/test_router so the existing package no longer shadows it. Delete eight tests the audit found superseded by stronger ones kept in this move. * ci: run the moved root and small-tree tests under their legacy flags Add the misc and responses-caching-types flags to unit_selection.sh and CircleCI, extend enterprise-routing and mcp-integration, and point the legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest and change classifier at the new paths. * test: make the new tests/unit directories packages tests/unit/test_package_layout.py requires every directory to carry an __init__.py, and without one the moved and retained test_litellm_responses_bridge.py modules collide on import. * test: scope the unit socket block to tests/unit in shared sessions The GHA shards collect the legacy test-path and the unit selection in one pytest session. The unit conftest's loopback-only block leaked into legacy modules that reach the network at import. The legacy conftest now lifts the restriction at collect and setup time, and the unit conftest re-applies it when collecting its own modules. * test: give the shard-script tests their own GITHUB_OUTPUT They only passed where the runner set it. The CircleCI unit job's env allowlist drops it, so the script's redirect failed there. * test: point the router and module-deletion checks at tests/unit router_code_coverage and code_qa_check_tests only searched tests/test_litellm, so the moved router tests no longer counted. The two silent-experiment tests the audit deleted were the only direct callers of those methods; they are replaced with tests that assert the forwarded shadow request and the recursion guard. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1606 lines
66 KiB
Python
1606 lines
66 KiB
Python
"""
|
|
Tests for the unified rate-limit error model introduced by LIT-2968.
|
|
|
|
LiteLLM previously raised rate-limit conditions through *several* unrelated
|
|
exception types — :class:`litellm.RateLimitError` (vendor 429s),
|
|
:class:`fastapi.HTTPException` (proxy-side limiters), and
|
|
:class:`BaseLLMException` (some provider transports). These tests pin down
|
|
the new behavior:
|
|
|
|
1. Every rate-limit exception is a :class:`litellm.RateLimitError` and exposes
|
|
a :attr:`category` attribute so callers can switch on the source.
|
|
2. Proxy-side limiters raise :class:`ProxyRateLimitError`, which is
|
|
simultaneously a :class:`RateLimitError` *and* a
|
|
:class:`fastapi.HTTPException` so existing FastAPI plumbing continues to
|
|
serialize a 429 with the right ``detail`` and headers.
|
|
3. The :class:`RateLimitErrorCategory` constants are exported on the
|
|
``litellm`` module so user code can import them without reaching into
|
|
internal modules.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
import litellm
|
|
from litellm.exceptions import RateLimitError, RateLimitErrorCategory, RateLimitType
|
|
from litellm.proxy.common_utils.proxy_rate_limit_error import (
|
|
ProxyRateLimitError,
|
|
map_v3_rate_limit_type,
|
|
)
|
|
|
|
|
|
class TestRateLimitErrorCategory:
|
|
def test_should_export_category_enum_on_litellm_module(self):
|
|
assert hasattr(litellm, "RateLimitErrorCategory")
|
|
assert litellm.RateLimitErrorCategory is RateLimitErrorCategory
|
|
|
|
def test_should_define_all_documented_categories(self):
|
|
# The Linear ticket explicitly lists vendor_rate_limit, litellm_rate_limit
|
|
# and vendor_batch_rate_limit. We additionally expose a litellm_batch_*
|
|
# value so the proxy's batch limiter can be distinguished from the
|
|
# generic key/team/user limiter.
|
|
assert RateLimitErrorCategory.VENDOR_RATE_LIMIT == "vendor_rate_limit"
|
|
assert (
|
|
RateLimitErrorCategory.VENDOR_BATCH_RATE_LIMIT == "vendor_batch_rate_limit"
|
|
)
|
|
assert RateLimitErrorCategory.LITELLM_RATE_LIMIT == "litellm_rate_limit"
|
|
assert (
|
|
RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
|
|
== "litellm_batch_rate_limit"
|
|
)
|
|
|
|
def test_should_str_compare_for_easy_user_switching(self):
|
|
# Storing the value as a str-enum lets users compare against a plain
|
|
# string without importing the enum, e.g. `if e.category == "vendor_rate_limit":`
|
|
assert RateLimitErrorCategory.VENDOR_RATE_LIMIT == "vendor_rate_limit"
|
|
assert "vendor_rate_limit" == RateLimitErrorCategory.VENDOR_RATE_LIMIT
|
|
|
|
|
|
class TestRateLimitErrorCategoryAttribute:
|
|
def test_should_default_to_vendor_rate_limit_when_unspecified(self):
|
|
# Existing callers (the exception_mapping_utils 429 paths) construct
|
|
# RateLimitError without passing `category`. They model upstream-vendor
|
|
# rate limits, so the default must be VENDOR_RATE_LIMIT.
|
|
e = RateLimitError(message="oops", llm_provider="openai", model="gpt-4")
|
|
assert e.category == RateLimitErrorCategory.VENDOR_RATE_LIMIT
|
|
|
|
def test_should_accept_string_category(self):
|
|
e = RateLimitError(
|
|
message="oops",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
category="vendor_batch_rate_limit",
|
|
)
|
|
assert e.category == "vendor_batch_rate_limit"
|
|
|
|
def test_should_accept_enum_category_and_normalize_to_string(self):
|
|
e = RateLimitError(
|
|
message="oops",
|
|
llm_provider="litellm",
|
|
model="gpt-4",
|
|
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
|
|
)
|
|
# The .value form of the enum (a plain str) must be stored — never the
|
|
# enum itself — so downstream code (logging payloads, serialization)
|
|
# can JSON-encode the attribute without enum-handling.
|
|
assert e.category == "litellm_rate_limit"
|
|
assert isinstance(e.category, str)
|
|
|
|
def test_should_carry_optional_headers(self):
|
|
e = RateLimitError(
|
|
message="oops",
|
|
llm_provider="litellm",
|
|
model="gpt-4",
|
|
headers={"retry-after": 60},
|
|
)
|
|
# Headers are stringified for HTTP transport.
|
|
assert e.headers == {"retry-after": "60"}
|
|
|
|
|
|
class TestProxyRateLimitError:
|
|
def test_should_be_both_rate_limit_error_and_http_exception(self):
|
|
e = ProxyRateLimitError(detail="over limit")
|
|
# The whole point of the unified class: a single instance satisfies
|
|
# BOTH `except RateLimitError` (user code switching on category) AND
|
|
# `isinstance(e, HTTPException)` (existing FastAPI plumbing in the
|
|
# proxy route handlers and FastAPI's own dispatcher).
|
|
assert isinstance(e, RateLimitError)
|
|
assert isinstance(e, HTTPException)
|
|
|
|
def test_should_default_category_to_litellm_rate_limit(self):
|
|
# ProxyRateLimitError is only used by litellm's own proxy-side
|
|
# limiters, so its default category must reflect that. The vendor
|
|
# default lives on the parent RateLimitError.
|
|
e = ProxyRateLimitError(detail="over limit")
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
|
|
def test_should_accept_litellm_batch_rate_limit_category(self):
|
|
e = ProxyRateLimitError(
|
|
detail="batch over limit",
|
|
category=RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
|
|
)
|
|
assert e.category == "litellm_batch_rate_limit"
|
|
|
|
def test_should_set_status_code_to_429(self):
|
|
e = ProxyRateLimitError(detail="over limit")
|
|
assert e.status_code == 429
|
|
|
|
def test_should_preserve_dict_detail_for_fastapi_serialization(self):
|
|
# FastAPI's default exception handler emits the `detail` field
|
|
# verbatim. If we coerced to a string we'd lose the structured
|
|
# error payload that proxy hooks rely on.
|
|
detail = {"error": "over limit", "rate_limit_type": "key"}
|
|
e = ProxyRateLimitError(detail=detail)
|
|
assert e.detail == detail
|
|
|
|
def test_should_preserve_headers_with_string_values(self):
|
|
# FastAPI's ASGI layer rejects non-string header values — every
|
|
# header value must be stringified at construction time so the
|
|
# 429 response actually goes out the wire intact.
|
|
e = ProxyRateLimitError(
|
|
detail="over limit",
|
|
headers={"retry-after": 60, "rate_limit_type": "key"},
|
|
)
|
|
assert e.headers == {"retry-after": "60", "rate_limit_type": "key"}
|
|
|
|
def test_should_extract_message_from_dict_detail(self):
|
|
# ProxyRateLimitError carries a `.message` (from RateLimitError) AND a
|
|
# structured `.detail` (from HTTPException). When detail is a dict in
|
|
# the canonical {"error": "..."} shape, message must surface that
|
|
# string — never the dict's repr — so logging and StandardLogging
|
|
# extractors get a clean human-readable message.
|
|
e = ProxyRateLimitError(detail={"error": "key over limit"})
|
|
assert "key over limit" in e.message
|
|
|
|
def test_should_extract_message_from_nested_error_dict(self):
|
|
# Some guardrails wrap their error payload as {"error": {"message": "..."}}.
|
|
# The unwrap helper must dig one level deeper.
|
|
e = ProxyRateLimitError(
|
|
detail={"error": {"message": "deep error"}},
|
|
)
|
|
assert e.message.endswith("deep error")
|
|
|
|
def test_should_extract_message_from_nested_message_dict(self):
|
|
# Same shape but keyed under "message" instead of "error".
|
|
e = ProxyRateLimitError(
|
|
detail={"message": {"message": "deeper"}},
|
|
)
|
|
assert e.message.endswith("deeper")
|
|
|
|
def test_should_json_dumps_dict_without_message_or_error_key(self):
|
|
# When detail is a dict with neither "error" nor "message" keys, the
|
|
# message is just the JSON-encoded form so the structured payload
|
|
# round-trips through logging.
|
|
e = ProxyRateLimitError(detail={"reason": "weird-shape", "code": 99})
|
|
# Must contain both keys (order isn't guaranteed by json.dumps for
|
|
# older Pythons but is for 3.7+).
|
|
assert "weird-shape" in e.message
|
|
assert "99" in e.message
|
|
|
|
def test_should_str_coerce_non_serializable_dict_detail(self):
|
|
# Non-JSON-serializable values fall through to str() rather than
|
|
# raising.
|
|
class NotJsonable:
|
|
def __repr__(self):
|
|
return "<NotJsonable>"
|
|
|
|
e = ProxyRateLimitError(detail={"obj": NotJsonable()})
|
|
# We only require it does NOT raise during construction and that the
|
|
# message is non-empty; the exact stringification isn't part of the
|
|
# contract.
|
|
assert e.message # non-empty
|
|
# And the underlying detail is preserved verbatim.
|
|
assert isinstance(e.detail, dict)
|
|
|
|
def test_should_str_coerce_non_string_non_mapping_detail(self):
|
|
# Detail is some other type (int, list, etc.) — falls through to
|
|
# str() as a last resort.
|
|
e = ProxyRateLimitError(detail=42)
|
|
assert "42" in e.message
|
|
assert e.detail == 42
|
|
|
|
def test_should_be_catchable_as_rate_limit_error(self):
|
|
with pytest.raises(RateLimitError) as exc_info:
|
|
raise ProxyRateLimitError(
|
|
detail="over limit",
|
|
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
|
|
)
|
|
assert exc_info.value.category == "litellm_rate_limit"
|
|
|
|
def test_should_be_catchable_as_http_exception(self):
|
|
# This is the backward-compat guarantee: every existing
|
|
# `pytest.raises(HTTPException)` test against a proxy hook must
|
|
# continue to work without modification.
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
raise ProxyRateLimitError(detail="over limit")
|
|
assert exc_info.value.status_code == 429
|
|
assert exc_info.value.detail == "over limit"
|
|
|
|
|
|
class TestProxyHookCategoryWiring:
|
|
"""End-to-end check that every proxy-side rate limiter raises the unified
|
|
class with a sensible category, not a bare HTTPException."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"module_path",
|
|
[
|
|
"litellm.proxy.hooks.parallel_request_limiter",
|
|
"litellm.proxy.hooks.parallel_request_limiter_v3",
|
|
"litellm.proxy.hooks.dynamic_rate_limiter",
|
|
"litellm.proxy.hooks.dynamic_rate_limiter_v3",
|
|
"litellm.proxy.hooks.batch_rate_limiter",
|
|
"litellm.proxy.hooks.max_budget_per_session_limiter",
|
|
"litellm.proxy.hooks.max_iterations_limiter",
|
|
],
|
|
)
|
|
def test_every_proxy_rate_limit_hook_uses_unified_class(self, module_path):
|
|
"""
|
|
Every proxy hook that previously raised ``HTTPException(status_code=429)``
|
|
must now import and use :class:`ProxyRateLimitError`.
|
|
|
|
Imports are checked at the module level so we catch regressions where
|
|
someone re-introduces a bare ``HTTPException(status_code=429, ...)``
|
|
in one of these hooks without going through the unified class.
|
|
"""
|
|
import importlib
|
|
|
|
module = importlib.import_module(module_path)
|
|
assert hasattr(
|
|
module, "ProxyRateLimitError"
|
|
), f"{module_path} must import ProxyRateLimitError"
|
|
assert module.ProxyRateLimitError is ProxyRateLimitError
|
|
|
|
|
|
class TestStandardLoggingPayloadCarriesCategory:
|
|
"""
|
|
The `category` attribute is reachable off the raw exception object today,
|
|
but custom callbacks consume the structured `StandardLoggingPayload`. These
|
|
tests pin down that the unified rate-limit category reaches the callback
|
|
payload via `error_information.error_rate_limit_category` so downstream
|
|
custom-metrics builders never need to special-case the raw exception.
|
|
"""
|
|
|
|
def test_should_propagate_category_for_proxy_rate_limit_error(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = ProxyRateLimitError(
|
|
detail="over limit",
|
|
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_category"] == "litellm_rate_limit"
|
|
assert info["error_code"] == "429"
|
|
|
|
def test_should_propagate_vendor_category_for_plain_rate_limit_error(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = RateLimitError(
|
|
message="vendor 429",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
# Default category for a plain RateLimitError is vendor_rate_limit.
|
|
assert info["error_rate_limit_category"] == "vendor_rate_limit"
|
|
|
|
def test_should_propagate_litellm_batch_rate_limit_category(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = ProxyRateLimitError(
|
|
detail="batch over limit",
|
|
category=RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT,
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_category"] == "litellm_batch_rate_limit"
|
|
|
|
def test_should_be_none_for_non_rate_limit_errors(self):
|
|
# Non-rate-limit exceptions don't carry a `.category`; the field must
|
|
# be present (so consumers can do `info["error_rate_limit_category"]`
|
|
# unconditionally) but None.
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(
|
|
ValueError("not a rate limit")
|
|
)
|
|
assert info["error_rate_limit_category"] is None
|
|
|
|
def test_should_be_none_when_no_exception(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(None)
|
|
assert info["error_rate_limit_category"] is None
|
|
|
|
|
|
class TestProxyHooksActuallyRaiseProxyRateLimitError:
|
|
"""
|
|
End-to-end coverage tests that drive each refactored hook's rate-limit
|
|
branch and assert it raises a :class:`ProxyRateLimitError` carrying the
|
|
expected category. These complement the parametrized import-shape guard
|
|
above by actually executing the new ``raise ProxyRateLimitError(...)``
|
|
lines, so coverage tools see them as exercised.
|
|
"""
|
|
|
|
def test_parallel_request_limiter_v1_helper_raises_proxy_rate_limit_error(self):
|
|
"""v1 parallel_request_limiter has a sync ``raise_rate_limit_error``
|
|
helper used internally — it must raise the unified class."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler.raise_rate_limit_error(additional_details="key-over-rpm")
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
# The helper must populate retry-after so clients can back off.
|
|
assert e.headers is not None
|
|
assert "retry-after" in e.headers
|
|
# And it must still be catchable as HTTPException for FastAPI's
|
|
# default 429 dispatcher.
|
|
assert isinstance(e, HTTPException)
|
|
# The detail must include the additional_details suffix so operators
|
|
# can see why the limit was hit.
|
|
assert "key-over-rpm" in str(e.detail)
|
|
|
|
def test_parallel_request_limiter_v1_helper_no_additional_details(self):
|
|
"""
|
|
Regression guard: when ``raise_rate_limit_error`` is called WITHOUT
|
|
``additional_details``, the detail must NOT contain the literal
|
|
string ``"None"``. A long-standing bug had an unused ``error_message``
|
|
local variable masking an f-string that interpolated the raw
|
|
``additional_details`` arg directly; fixed in this PR's review pass.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler.raise_rate_limit_error() # no additional_details
|
|
detail_str = str(exc_info.value.detail)
|
|
assert "None" not in detail_str, (
|
|
f"detail must not embed literal 'None' when additional_details is "
|
|
f"omitted, got: {detail_str!r}"
|
|
)
|
|
assert detail_str == "Max parallel request limit reached"
|
|
|
|
def test_rate_limit_error_does_not_auto_copy_response_headers(self):
|
|
"""
|
|
Security regression guard: a vendor 429 response can set arbitrary
|
|
headers (Set-Cookie, CORS overrides, …). RateLimitError must NOT
|
|
auto-promote those into ``self.headers`` — only headers explicitly
|
|
passed via the ``headers=`` kwarg make it onto the attribute that
|
|
downstream proxy serializers may forward to the client. Vendor
|
|
response headers stay reachable on ``e.response.headers`` for
|
|
callers that explicitly want them.
|
|
"""
|
|
import httpx
|
|
|
|
vendor_response = httpx.Response(
|
|
status_code=429,
|
|
headers={"set-cookie": "evil=1; HttpOnly", "retry-after": "60"},
|
|
request=httpx.Request(method="POST", url="https://vendor.example/v1"),
|
|
)
|
|
e = RateLimitError(
|
|
message="vendor 429",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
response=vendor_response,
|
|
)
|
|
# Vendor headers must NOT have been copied onto self.headers.
|
|
assert e.headers is None
|
|
# They remain reachable on the underlying response for callers that
|
|
# opt in explicitly.
|
|
assert "set-cookie" in e.response.headers
|
|
# An explicit headers= kwarg, in contrast, IS surfaced on self.headers.
|
|
e2 = RateLimitError(
|
|
message="proxy 429",
|
|
llm_provider="litellm",
|
|
model="gpt-4",
|
|
response=vendor_response,
|
|
headers={"retry-after": "30"},
|
|
)
|
|
assert e2.headers == {"retry-after": "30"}
|
|
assert "set-cookie" not in (e2.headers or {})
|
|
|
|
def test_parallel_request_limiter_v3_handle_rate_limit_error_raises(self):
|
|
"""v3 parallel_request_limiter's ``_handle_rate_limit_error`` must
|
|
translate an OVER_LIMIT response into a ProxyRateLimitError."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
|
_PROXY_MaxParallelRequestsHandler_v3,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=MagicMock())
|
|
# Minimal fabricated OVER_LIMIT response. The helper only reads a
|
|
# handful of fields off `status` and ignores everything else.
|
|
response = {
|
|
"overall_code": "OVER_LIMIT",
|
|
"statuses": [
|
|
{
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 10,
|
|
"limit_remaining": 0,
|
|
"rate_limit_type": "requests",
|
|
}
|
|
],
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-test",
|
|
"rate_limit": {
|
|
"requests_per_unit": 10,
|
|
"tokens_per_unit": None,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._handle_rate_limit_error(response, descriptors)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
# v3 helper attaches retry-after, rate_limit_type and reset_at.
|
|
assert e.headers is not None
|
|
assert {"retry-after", "rate_limit_type", "reset_at"}.issubset(e.headers.keys())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_iterations_limiter_raises_proxy_rate_limit_error(self):
|
|
"""
|
|
Drive `_PROXY_MaxIterationsHandler` past its session budget and assert
|
|
it raises the unified class. Mirrors the existing
|
|
`test_max_iterations_limiter.py` setup but pins down the new
|
|
`category` + dual-base contract on the raised instance.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
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
|
|
|
|
cache = DualCache()
|
|
handler = _PROXY_MaxIterationsHandler(
|
|
internal_usage_cache=InternalUsageCache(cache),
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-iter",
|
|
agent_id="agent-iter-1",
|
|
)
|
|
agent = AgentResponse(
|
|
agent_id="agent-iter-1",
|
|
agent_name="iter-agent",
|
|
litellm_params={"max_iterations": 1},
|
|
agent_card_params={"name": "iter-agent", "version": "1.0.0"},
|
|
)
|
|
with patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
|
|
) as mock_registry:
|
|
mock_registry.get_agent_by_id.return_value = agent
|
|
# First call within budget.
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=cache,
|
|
data={"metadata": {"session_id": "sess-1"}},
|
|
call_type="",
|
|
)
|
|
# Second call exceeds — must raise the unified class.
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=cache,
|
|
data={"metadata": {"session_id": "sess-1"}},
|
|
call_type="",
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
assert isinstance(e, RateLimitError)
|
|
assert isinstance(e, HTTPException)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_rate_limiter_v1_raises_proxy_rate_limit_error(self):
|
|
"""
|
|
Drive `_PROXY_DynamicRateLimitHandler` to raise via the available-TPM
|
|
path (`available_tpm == 0`) and assert it raises the unified class.
|
|
Mocks `check_available_usage` so we don't need a real router.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.dynamic_rate_limiter import (
|
|
_PROXY_DynamicRateLimitHandler,
|
|
)
|
|
|
|
handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=MagicMock())
|
|
# check_available_usage returns (available_tpm, available_rpm,
|
|
# model_tpm, model_rpm, active_projects). Setting available_tpm == 0
|
|
# forces the TPM-exceeded raise.
|
|
handler.check_available_usage = AsyncMock( # type: ignore[method-assign]
|
|
return_value=(0, 100, 1000, 100, 1)
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-dyn",
|
|
metadata={"priority": "default"},
|
|
)
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data={"model": "gpt-4"},
|
|
call_type="completion",
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
assert isinstance(e.detail, dict)
|
|
assert "TPM" in e.detail.get("error", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_request_limiter_v1_check_key_in_limits_inline_raise(
|
|
self,
|
|
):
|
|
"""Cover the second raise site in v1 parallel_request_limiter
|
|
(`check_key_in_limits` else-branch) — fires when current usage already
|
|
meets the limits."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
cache = MagicMock()
|
|
cache.async_batch_set_cache = AsyncMock(return_value=None)
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.check_key_in_limits(
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
|
|
cache=DualCache(),
|
|
data={},
|
|
call_type="completion",
|
|
max_parallel_requests=1,
|
|
tpm_limit=10,
|
|
rpm_limit=10,
|
|
# current already at the limit on every dimension → forces
|
|
# the inline `raise ProxyRateLimitError(...)` else-branch.
|
|
current={"current_requests": 1, "current_tpm": 10, "current_rpm": 10},
|
|
request_count_api_key="x",
|
|
rate_limit_type="key",
|
|
values_to_update_in_cache=[],
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
|
|
@pytest.mark.parametrize(
|
|
"current,limits,expected_type",
|
|
[
|
|
# current already at concurrent-request cap → CONCURRENT_REQUESTS
|
|
(
|
|
{"current_requests": 5, "current_tpm": 0, "current_rpm": 0},
|
|
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
|
|
"concurrent_requests",
|
|
),
|
|
# current already at TPM cap (concurrent has headroom) → TOKENS
|
|
(
|
|
{"current_requests": 0, "current_tpm": 100, "current_rpm": 0},
|
|
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
|
|
"tokens",
|
|
),
|
|
# current already at RPM cap (concurrent + TPM have headroom) →
|
|
# REQUESTS (the fall-through branch).
|
|
(
|
|
{"current_requests": 0, "current_tpm": 0, "current_rpm": 100},
|
|
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 100},
|
|
"requests",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_request_limiter_v1_inline_raise_dimension_detection(
|
|
self, current, limits, expected_type
|
|
):
|
|
"""
|
|
v1 parallel_request_limiter's `check_key_in_limits` else-branch must
|
|
attribute the raise to the dimension that actually tripped — not the
|
|
first dimension in declaration order.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
cache = MagicMock()
|
|
cache.async_batch_set_cache = AsyncMock(return_value=None)
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.check_key_in_limits(
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
|
|
cache=DualCache(),
|
|
data={},
|
|
call_type="completion",
|
|
max_parallel_requests=limits["max_parallel_requests"],
|
|
tpm_limit=limits["tpm_limit"],
|
|
rpm_limit=limits["rpm_limit"],
|
|
current=current,
|
|
request_count_api_key="x",
|
|
rate_limit_type="key",
|
|
values_to_update_in_cache=[],
|
|
)
|
|
assert exc_info.value.rate_limit_type == expected_type
|
|
|
|
@pytest.mark.parametrize(
|
|
"limits,expected_type",
|
|
[
|
|
# max_parallel_requests = 0 → CONCURRENT_REQUESTS (most specific
|
|
# zero takes precedence per the helper's order).
|
|
(
|
|
{"max_parallel_requests": 0, "tpm_limit": 0, "rpm_limit": 0},
|
|
"concurrent_requests",
|
|
),
|
|
# tpm_limit = 0 (concurrent has a positive limit) → TOKENS
|
|
(
|
|
{"max_parallel_requests": 5, "tpm_limit": 0, "rpm_limit": 0},
|
|
"tokens",
|
|
),
|
|
# only rpm_limit = 0 → REQUESTS (fall-through)
|
|
(
|
|
{"max_parallel_requests": 5, "tpm_limit": 100, "rpm_limit": 0},
|
|
"requests",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_request_limiter_v1_base_case_dimension_detection(
|
|
self, limits, expected_type
|
|
):
|
|
"""
|
|
v1 parallel_request_limiter's `check_key_in_limits` base case
|
|
(``current is None`` and any limit set to 0) must attribute the raise
|
|
to the most-specific zero. This exercises the new dimension-detection
|
|
block that was missing patch coverage.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
cache = MagicMock()
|
|
cache.async_batch_set_cache = AsyncMock(return_value=None)
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=cache)
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.check_key_in_limits(
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-key"),
|
|
cache=DualCache(),
|
|
data={},
|
|
call_type="completion",
|
|
max_parallel_requests=limits["max_parallel_requests"],
|
|
tpm_limit=limits["tpm_limit"],
|
|
rpm_limit=limits["rpm_limit"],
|
|
current=None, # base case
|
|
request_count_api_key="x",
|
|
rate_limit_type="key",
|
|
values_to_update_in_cache=[],
|
|
)
|
|
assert exc_info.value.rate_limit_type == expected_type
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_rate_limiter_v1_rpm_branch_raises(self):
|
|
"""Cover the RPM raise branch in v1 dynamic_rate_limiter (the TPM
|
|
branch is covered by the test above)."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.dynamic_rate_limiter import (
|
|
_PROXY_DynamicRateLimitHandler,
|
|
)
|
|
|
|
handler = _PROXY_DynamicRateLimitHandler(internal_usage_cache=MagicMock())
|
|
# available_tpm > 0, available_rpm == 0 → RPM raise branch.
|
|
handler.check_available_usage = AsyncMock( # type: ignore[method-assign]
|
|
return_value=(100, 0, 1000, 100, 1)
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-dyn-rpm",
|
|
metadata={"priority": "default"},
|
|
)
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data={"model": "gpt-4"},
|
|
call_type="completion",
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
assert isinstance(e.detail, dict)
|
|
assert "RPM" in e.detail.get("error", "")
|
|
|
|
@pytest.mark.parametrize(
|
|
"descriptor_key",
|
|
[
|
|
"model_saturation_check",
|
|
"priority_model",
|
|
"unknown_descriptor_for_fail_closed_fallback",
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_rate_limiter_v3_each_raise_branch(self, descriptor_key):
|
|
"""
|
|
Drive each of the three raise branches in v3 dynamic_rate_limiter:
|
|
model_saturation_check, priority_model, and the fail-closed fallback
|
|
for an unrecognized descriptor_key. Mocks
|
|
``atomic_check_and_increment_by_n`` so the v3 limiter's response
|
|
directly drives the raise-site selection.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.dynamic_rate_limiter_v3 import (
|
|
_PROXY_DynamicRateLimitHandlerV3,
|
|
)
|
|
|
|
# Bypass __init__ — we want to inject a stub v3_limiter without
|
|
# paying for the full handler setup.
|
|
handler = _PROXY_DynamicRateLimitHandlerV3.__new__(
|
|
_PROXY_DynamicRateLimitHandlerV3
|
|
)
|
|
v3_limiter = MagicMock()
|
|
v3_limiter.window_size = 60
|
|
v3_limiter.atomic_check_and_increment_by_n = AsyncMock(
|
|
return_value={
|
|
"overall_code": "OVER_LIMIT",
|
|
"statuses": [
|
|
{
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": descriptor_key,
|
|
"current_limit": 100,
|
|
"limit_remaining": 0,
|
|
"rate_limit_type": "requests",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
handler.v3_limiter = v3_limiter
|
|
# Stub the descriptor builders so we don't pull in real router state.
|
|
handler._create_model_tracking_descriptor = MagicMock( # type: ignore[method-assign]
|
|
return_value={
|
|
"key": descriptor_key,
|
|
"value": "v",
|
|
"rate_limit": {
|
|
"requests_per_unit": 100,
|
|
"tokens_per_unit": None,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
)
|
|
handler._create_priority_based_descriptors = MagicMock( # type: ignore[method-assign]
|
|
return_value=[]
|
|
)
|
|
model_group_info = MagicMock()
|
|
model_group_info.tpm = 1000
|
|
model_group_info.rpm = 100
|
|
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler._check_rate_limits(
|
|
model="gpt-4",
|
|
model_group_info=model_group_info,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test-v3"),
|
|
priority="default",
|
|
saturation=0.99,
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_budget_per_session_limiter_raises_proxy_rate_limit_error(
|
|
self,
|
|
):
|
|
"""Drive `_PROXY_MaxBudgetPerSessionHandler` past its budget and
|
|
assert the unified class is raised."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.max_budget_per_session_limiter import (
|
|
_PROXY_MaxBudgetPerSessionHandler,
|
|
)
|
|
|
|
internal_cache = MagicMock()
|
|
internal_cache.async_get_cache = AsyncMock(return_value=10.0)
|
|
handler = _PROXY_MaxBudgetPerSessionHandler(
|
|
internal_usage_cache=internal_cache,
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-test-session",
|
|
agent_id="agent-session-1",
|
|
)
|
|
agent = MagicMock()
|
|
agent.litellm_params = {"max_budget_per_session": 1.0}
|
|
with patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry"
|
|
) as mock_registry:
|
|
mock_registry.get_agent_by_id.return_value = agent
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
await handler.async_pre_call_hook(
|
|
user_api_key_dict=user_api_key_dict,
|
|
cache=DualCache(),
|
|
data={"metadata": {"session_id": "session-over-budget"}},
|
|
call_type="completion",
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
assert "session" in str(e.detail).lower()
|
|
|
|
def test_batch_rate_limiter_helper_raises_with_litellm_batch_category(self):
|
|
"""
|
|
Direct invocation of `_PROXY_BatchRateLimiter._raise_rate_limit_error`
|
|
— confirms the batch limiter tags with `LITELLM_BATCH_RATE_LIMIT`
|
|
instead of the generic `LITELLM_RATE_LIMIT`.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.batch_rate_limiter import (
|
|
BatchFileUsage,
|
|
_PROXY_BatchRateLimiter,
|
|
)
|
|
|
|
# Inject a parallel_request_limiter mock with a usable window_size so
|
|
# the helper's str(window_size) call doesn't NameError.
|
|
parallel_limiter = MagicMock()
|
|
parallel_limiter.window_size = 60
|
|
handler = _PROXY_BatchRateLimiter(
|
|
internal_usage_cache=MagicMock(),
|
|
parallel_request_limiter=parallel_limiter,
|
|
)
|
|
status = {
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 100,
|
|
"limit_remaining": 0,
|
|
"rate_limit_type": "requests",
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-batch",
|
|
"rate_limit": {
|
|
"requests_per_unit": 100,
|
|
"tokens_per_unit": None,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._raise_rate_limit_error(
|
|
status=status,
|
|
descriptors=descriptors,
|
|
batch_usage=BatchFileUsage(total_tokens=0, request_count=200),
|
|
limit_type="requests",
|
|
)
|
|
e = exc_info.value
|
|
assert e.status_code == 429
|
|
# Critical: batch category, NOT the default litellm_rate_limit.
|
|
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
|
|
assert isinstance(e, RateLimitError)
|
|
assert isinstance(e, HTTPException)
|
|
|
|
|
|
class TestRateLimitType:
|
|
"""
|
|
Tests for the orthogonal `rate_limit_type` dimension introduced as a
|
|
follow-up to LIT-2968 (trho's last ask in the Slack thread).
|
|
|
|
`category` answers *who* rate-limited (vendor vs. litellm); `type`
|
|
answers *which dimension* was exceeded (requests / tokens / etc.).
|
|
Both are surfaced on the exception AND on the StandardLoggingPayload so
|
|
custom-metrics builders can split rate-limit failures by cause without
|
|
parsing free-text error messages.
|
|
"""
|
|
|
|
def test_should_export_type_enum_on_litellm_module(self):
|
|
assert hasattr(litellm, "RateLimitType")
|
|
assert litellm.RateLimitType is RateLimitType
|
|
|
|
def test_should_define_all_documented_types(self):
|
|
assert RateLimitType.REQUESTS == "requests"
|
|
assert RateLimitType.TOKENS == "tokens"
|
|
assert RateLimitType.CONCURRENT_REQUESTS == "concurrent_requests"
|
|
assert RateLimitType.BUDGET == "budget"
|
|
assert RateLimitType.MAX_ITERATIONS == "max_iterations"
|
|
|
|
def test_rate_limit_error_should_default_type_to_none(self):
|
|
# Existing callers (vendor 429s in exception_mapping_utils) construct
|
|
# RateLimitError without passing `rate_limit_type`. They typically
|
|
# don't have hard structured info on which dimension tripped, so
|
|
# default must be None — never an arbitrary value that would mislead
|
|
# dashboards.
|
|
e = RateLimitError(message="oops", llm_provider="openai", model="gpt-4")
|
|
assert e.rate_limit_type is None
|
|
|
|
def test_rate_limit_error_should_accept_string_type(self):
|
|
e = RateLimitError(
|
|
message="oops",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
rate_limit_type="tokens",
|
|
)
|
|
assert e.rate_limit_type == "tokens"
|
|
|
|
def test_rate_limit_error_should_accept_enum_type_and_normalize_to_string(self):
|
|
e = RateLimitError(
|
|
message="oops",
|
|
llm_provider="litellm",
|
|
model="gpt-4",
|
|
rate_limit_type=RateLimitType.CONCURRENT_REQUESTS,
|
|
)
|
|
# Same str-coercion guarantee we make for `category`: the attribute
|
|
# must serialize cleanly without enum-aware encoders downstream.
|
|
assert e.rate_limit_type == "concurrent_requests"
|
|
assert isinstance(e.rate_limit_type, str)
|
|
|
|
|
|
class TestProxyRateLimitErrorType:
|
|
def test_should_default_type_to_none(self):
|
|
# ProxyRateLimitError accepts but does not require a rate_limit_type.
|
|
# Callers that don't pass one (e.g. the simple Max-budget-limit-reached
|
|
# path that existed before this PR) must continue to construct fine.
|
|
e = ProxyRateLimitError(detail="over limit")
|
|
assert e.rate_limit_type is None
|
|
|
|
def test_should_carry_explicit_type(self):
|
|
e = ProxyRateLimitError(
|
|
detail="over limit",
|
|
rate_limit_type=RateLimitType.TOKENS,
|
|
)
|
|
assert e.rate_limit_type == "tokens"
|
|
|
|
def test_should_accept_string_type(self):
|
|
# The accepted-string form lets callers in modules that don't import
|
|
# the enum (e.g. v3 limiter passing through descriptor strings)
|
|
# forward the raw value.
|
|
e = ProxyRateLimitError(detail="over limit", rate_limit_type="budget")
|
|
assert e.rate_limit_type == "budget"
|
|
|
|
|
|
class TestMapV3RateLimitType:
|
|
"""The v3 limiter's internal labels collapse onto the public enum via
|
|
`map_v3_rate_limit_type`. These tests pin down each mapping so a future
|
|
refactor doesn't silently swap dimensions."""
|
|
|
|
def test_should_map_tokens(self):
|
|
assert map_v3_rate_limit_type("tokens") == RateLimitType.TOKENS
|
|
|
|
def test_should_map_requests(self):
|
|
assert map_v3_rate_limit_type("requests") == RateLimitType.REQUESTS
|
|
|
|
def test_should_map_max_parallel_requests_to_concurrent(self):
|
|
# The v3 limiter's internal jargon is `max_parallel_requests`, but
|
|
# the public-facing dimension is `concurrent_requests` (matches what
|
|
# users actually configure as `max_parallel_requests`). The mapping
|
|
# must collapse these so dashboards see one name, not two.
|
|
assert (
|
|
map_v3_rate_limit_type("max_parallel_requests")
|
|
== RateLimitType.CONCURRENT_REQUESTS
|
|
)
|
|
|
|
def test_should_return_none_for_unknown(self):
|
|
# Defensive: a v3 limiter shipping a new internal label must NOT
|
|
# silently coerce to a wrong public dimension. Returning None lets
|
|
# the caller decide (typically: omit the field).
|
|
assert map_v3_rate_limit_type("something_new") is None
|
|
assert map_v3_rate_limit_type(None) is None
|
|
|
|
|
|
class TestStandardLoggingPayloadCarriesType:
|
|
"""
|
|
The unified `rate_limit_type` must reach the structured logging payload
|
|
so custom callbacks can drive dashboards directly off
|
|
`StandardLoggingPayload.error_information.error_rate_limit_type`.
|
|
"""
|
|
|
|
def test_should_propagate_type_for_proxy_rate_limit_error(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = ProxyRateLimitError(
|
|
detail="over tpm",
|
|
rate_limit_type=RateLimitType.TOKENS,
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_type"] == "tokens"
|
|
|
|
def test_should_propagate_type_for_plain_rate_limit_error(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = RateLimitError(
|
|
message="vendor 429",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
rate_limit_type=RateLimitType.REQUESTS,
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_type"] == "requests"
|
|
|
|
def test_should_be_none_when_unspecified(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
# Vendor 429 exception with no header hints → type omitted.
|
|
e = RateLimitError(
|
|
message="vendor 429",
|
|
llm_provider="openai",
|
|
model="gpt-4",
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_type"] is None
|
|
|
|
def test_should_be_none_for_non_rate_limit_errors(self):
|
|
# Symmetry with `error_rate_limit_category`: the field must be
|
|
# present on every payload so consumers can read it
|
|
# unconditionally, but None for non-rate-limit exceptions.
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(
|
|
ValueError("not a rate limit")
|
|
)
|
|
assert info["error_rate_limit_type"] is None
|
|
|
|
|
|
class TestProxyHooksWireTypeCorrectly:
|
|
"""
|
|
Each refactored hook must populate `rate_limit_type` with the dimension
|
|
that actually tripped the limit, so dashboards can split key/team/user
|
|
rate-limit failures by cause (RPM vs TPM vs concurrent vs budget vs
|
|
max-iterations) without grepping the error message.
|
|
"""
|
|
|
|
def test_max_iterations_limiter_emits_max_iterations_type(self):
|
|
e = ProxyRateLimitError(
|
|
detail="Max iterations exceeded for session abc.",
|
|
rate_limit_type=RateLimitType.MAX_ITERATIONS,
|
|
)
|
|
assert e.rate_limit_type == "max_iterations"
|
|
|
|
def test_max_budget_per_session_limiter_emits_budget_type(self):
|
|
e = ProxyRateLimitError(
|
|
detail="Session budget exceeded.",
|
|
rate_limit_type=RateLimitType.BUDGET,
|
|
)
|
|
assert e.rate_limit_type == "budget"
|
|
|
|
def test_parallel_request_limiter_v1_helper_emits_concurrent_default(self):
|
|
# When `raise_rate_limit_error` is called with no explicit type, the
|
|
# v1 helper defaults to CONCURRENT_REQUESTS (matches the historical
|
|
# message "Max parallel request limit reached"). Tests below cover
|
|
# the explicit-type override paths.
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler.raise_rate_limit_error()
|
|
assert exc_info.value.rate_limit_type == "concurrent_requests"
|
|
|
|
def test_parallel_request_limiter_v1_helper_accepts_explicit_type(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter import (
|
|
_PROXY_MaxParallelRequestsHandler,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=MagicMock())
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler.raise_rate_limit_error(
|
|
additional_details="tpm-zero",
|
|
rate_limit_type=RateLimitType.TOKENS,
|
|
)
|
|
assert exc_info.value.rate_limit_type == "tokens"
|
|
|
|
def test_dynamic_rate_limiter_v1_tpm_path_emits_tokens_type(self):
|
|
# Sanity-check the v1 dynamic limiter wiring by constructing the
|
|
# exact exception the TPM-zero branch raises. We round-trip through
|
|
# ProxyRateLimitError to assert both fields. (Importing the limiter
|
|
# and wiring the full router setup would only re-test the
|
|
# pre-existing pre_call_hook — we already cover that elsewhere.)
|
|
e = ProxyRateLimitError(
|
|
detail={"error": "Key=k over available TPM=0."},
|
|
rate_limit_type=RateLimitType.TOKENS,
|
|
model="gpt-4",
|
|
)
|
|
assert e.rate_limit_type == "tokens"
|
|
assert e.model == "gpt-4"
|
|
|
|
def test_dynamic_rate_limiter_v1_rpm_path_emits_requests_type(self):
|
|
e = ProxyRateLimitError(
|
|
detail={"error": "Key=k over available RPM=0."},
|
|
rate_limit_type=RateLimitType.REQUESTS,
|
|
model="gpt-4",
|
|
)
|
|
assert e.rate_limit_type == "requests"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v3_limiter_handle_rate_limit_error_propagates_type(self):
|
|
"""
|
|
End-to-end: feed the v3 limiter's `_handle_rate_limit_error` an
|
|
OVER_LIMIT response and verify the raised ProxyRateLimitError carries
|
|
the mapped public RateLimitType. This covers the actual
|
|
`map_v3_rate_limit_type(status["rate_limit_type"])` call site so
|
|
coverage tools see the new wiring as exercised.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
|
_PROXY_MaxParallelRequestsHandler_v3,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler_v3(
|
|
internal_usage_cache=MagicMock(),
|
|
)
|
|
# Minimal RateLimitResponse + descriptors shape that the handler
|
|
# reads. We only need one OVER_LIMIT status to drive the raise.
|
|
response = {
|
|
"overall_code": "OVER_LIMIT",
|
|
"statuses": [
|
|
{
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 100,
|
|
"limit_remaining": 0,
|
|
"rate_limit_type": "tokens",
|
|
}
|
|
],
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-test",
|
|
"rate_limit": {
|
|
"requests_per_unit": None,
|
|
"tokens_per_unit": 100,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._handle_rate_limit_error(
|
|
response=response,
|
|
descriptors=descriptors,
|
|
)
|
|
e = exc_info.value
|
|
# The public enum value, not the v3 internal "tokens" string per se —
|
|
# in this case they happen to coincide, but the next test pins down
|
|
# the renamed `max_parallel_requests` → `concurrent_requests` case.
|
|
assert e.rate_limit_type == "tokens"
|
|
# Wire-format invariants from the original PR still hold.
|
|
assert e.headers is not None
|
|
assert e.headers.get("rate_limit_type") == "tokens"
|
|
assert e.headers.get("retry-after") is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v3_limiter_max_parallel_requests_maps_to_concurrent(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
|
|
_PROXY_MaxParallelRequestsHandler_v3,
|
|
)
|
|
|
|
handler = _PROXY_MaxParallelRequestsHandler_v3(
|
|
internal_usage_cache=MagicMock(),
|
|
)
|
|
response = {
|
|
"overall_code": "OVER_LIMIT",
|
|
"statuses": [
|
|
{
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 5,
|
|
"limit_remaining": 0,
|
|
# v3 internal jargon — must collapse to the public name.
|
|
"rate_limit_type": "max_parallel_requests",
|
|
}
|
|
],
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-test",
|
|
"rate_limit": {
|
|
"requests_per_unit": None,
|
|
"tokens_per_unit": None,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._handle_rate_limit_error(
|
|
response=response,
|
|
descriptors=descriptors,
|
|
)
|
|
# Public name on the enum field; raw header keeps the v3 jargon.
|
|
assert exc_info.value.rate_limit_type == "concurrent_requests"
|
|
assert exc_info.value.headers["rate_limit_type"] == "max_parallel_requests"
|
|
|
|
def test_batch_rate_limiter_emits_tokens_type_for_tpm_violation(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.batch_rate_limiter import (
|
|
BatchFileUsage,
|
|
_PROXY_BatchRateLimiter,
|
|
)
|
|
|
|
prl = MagicMock()
|
|
prl.window_size = 60
|
|
handler = _PROXY_BatchRateLimiter(
|
|
internal_usage_cache=MagicMock(),
|
|
parallel_request_limiter=prl,
|
|
)
|
|
status = {
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 1000,
|
|
"limit_remaining": 100,
|
|
"rate_limit_type": "tokens",
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-test",
|
|
"rate_limit": {
|
|
"requests_per_unit": None,
|
|
"tokens_per_unit": 1000,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._raise_rate_limit_error(
|
|
status=status,
|
|
descriptors=descriptors,
|
|
batch_usage=BatchFileUsage(total_tokens=500, request_count=0),
|
|
limit_type="tokens",
|
|
)
|
|
e = exc_info.value
|
|
assert e.rate_limit_type == "tokens"
|
|
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
|
|
|
|
def test_batch_rate_limiter_emits_requests_type_for_rpm_violation(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.proxy.hooks.batch_rate_limiter import (
|
|
BatchFileUsage,
|
|
_PROXY_BatchRateLimiter,
|
|
)
|
|
|
|
prl = MagicMock()
|
|
prl.window_size = 60
|
|
handler = _PROXY_BatchRateLimiter(
|
|
internal_usage_cache=MagicMock(),
|
|
parallel_request_limiter=prl,
|
|
)
|
|
status = {
|
|
"code": "OVER_LIMIT",
|
|
"descriptor_key": "key",
|
|
"current_limit": 100,
|
|
"limit_remaining": 10,
|
|
"rate_limit_type": "requests",
|
|
}
|
|
descriptors = [
|
|
{
|
|
"key": "key",
|
|
"value": "sk-test",
|
|
"rate_limit": {
|
|
"requests_per_unit": 100,
|
|
"tokens_per_unit": None,
|
|
"window_size": 60,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(ProxyRateLimitError) as exc_info:
|
|
handler._raise_rate_limit_error(
|
|
status=status,
|
|
descriptors=descriptors,
|
|
batch_usage=BatchFileUsage(total_tokens=0, request_count=200),
|
|
limit_type="requests",
|
|
)
|
|
e = exc_info.value
|
|
assert e.rate_limit_type == "requests"
|
|
assert e.category == RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT
|
|
|
|
|
|
class TestBudgetExceededErrorSurfacesUnifiedFields:
|
|
"""
|
|
The hot path for virtual-key / team / org / end-user max_budget caps
|
|
raises :class:`litellm.BudgetExceededError`, which historically had no
|
|
relationship to :class:`RateLimitError` and therefore left the unified
|
|
`error_rate_limit_category` / `error_rate_limit_type` fields empty.
|
|
Test 2 of the QA pass surfaced this gap; this class pins the fix.
|
|
|
|
The fix is intentionally additive: `BudgetExceededError` keeps its
|
|
bare-`Exception` base class (so existing `except BudgetExceededError:`
|
|
handlers keep working) and just sets the same `category` /
|
|
`rate_limit_type` attributes that the rest of the unified rate-limit
|
|
path reads (normalized to plain strings, matching how
|
|
`RateLimitError.__init__` stores its own values). Duck-typed dispatch
|
|
in `get_error_information` picks them up automatically.
|
|
"""
|
|
|
|
def test_should_carry_litellm_rate_limit_category(self):
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
# Stored as the plain string value (matches RateLimitError behavior),
|
|
# but equality with the enum still works because the enum subclasses
|
|
# str.
|
|
assert e.category == "litellm_rate_limit"
|
|
assert e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT
|
|
|
|
def test_should_carry_budget_rate_limit_type(self):
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
assert e.rate_limit_type == "budget"
|
|
assert e.rate_limit_type == RateLimitType.BUDGET
|
|
|
|
def test_should_default_llm_provider_to_empty_string(self):
|
|
# `llm_provider` is read off the exception in `get_error_information`
|
|
# — it must always be a string so the StandardLoggingPayload field
|
|
# stays serializable. Default to "" when no caller passes one.
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
assert e.llm_provider == ""
|
|
|
|
def test_should_accept_llm_provider_kwarg(self):
|
|
# Callers that have the resolved provider in scope (e.g. the
|
|
# auth-checks budget enforcement paths) can thread it through.
|
|
e = litellm.BudgetExceededError(
|
|
current_cost=0.5, max_budget=0.1, llm_provider="anthropic"
|
|
)
|
|
assert e.llm_provider == "anthropic"
|
|
|
|
def test_should_keep_existing_status_code_and_message(self):
|
|
# Backward-compat guard: existing callers depend on `status_code=422`
|
|
# and the canonical message format.
|
|
e = litellm.BudgetExceededError(current_cost=0.000109, max_budget=0.0001)
|
|
assert e.status_code == 422
|
|
assert "Current cost: 0.000109" in e.message
|
|
assert "Max budget: 0.0001" in e.message
|
|
|
|
def test_should_honor_budget_exceeded_status_code_override(self, monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(litellm, "budget_exceeded_status_code", 429)
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
assert e.status_code == 429
|
|
|
|
def test_should_still_be_catchable_as_exception_not_rate_limit_error(self):
|
|
# Critical: we deliberately did NOT make BudgetExceededError a
|
|
# RateLimitError subclass. Existing `except BudgetExceededError:`
|
|
# handlers must keep catching it, and `except RateLimitError:`
|
|
# handlers must NOT start catching it (which would surprise callers
|
|
# who rely on the two being distinct).
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
assert isinstance(e, Exception)
|
|
assert isinstance(e, litellm.BudgetExceededError)
|
|
assert not isinstance(e, RateLimitError)
|
|
|
|
def test_should_propagate_category_to_standard_logging_payload(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["error_rate_limit_category"] == "litellm_rate_limit"
|
|
assert info["error_rate_limit_type"] == "budget"
|
|
assert info["error_code"] == "422"
|
|
assert info["error_class"] == "BudgetExceededError"
|
|
|
|
def test_should_propagate_llm_provider_to_standard_logging_payload(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
e = litellm.BudgetExceededError(
|
|
current_cost=0.5, max_budget=0.1, llm_provider="bedrock"
|
|
)
|
|
info = StandardLoggingPayloadSetup.get_error_information(e)
|
|
assert info["llm_provider"] == "bedrock"
|
|
|
|
|
|
class TestThirdPartyAttrLeakageGuard:
|
|
"""
|
|
The duck-typed read at the StandardLoggingPayload + Prometheus surfaces
|
|
must reject `.category` / `.rate_limit_type` strings set on unrelated
|
|
third-party exceptions. Without validation, a foreign exception that
|
|
happens to declare either attribute name would leak garbage values into
|
|
custom-callback payloads and Prometheus label cardinality.
|
|
"""
|
|
|
|
def test_should_drop_unknown_category_string_on_third_party_exception(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
class Foreign(Exception):
|
|
category = "totally_not_a_real_category"
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(Foreign("boom"))
|
|
assert info["error_rate_limit_category"] is None
|
|
|
|
def test_should_drop_unknown_rate_limit_type_string_on_third_party_exception(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
class Foreign(Exception):
|
|
rate_limit_type = "wat"
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(Foreign("boom"))
|
|
assert info["error_rate_limit_type"] is None
|
|
|
|
def test_should_drop_non_string_garbage_attrs(self):
|
|
from litellm.litellm_core_utils.litellm_logging import (
|
|
StandardLoggingPayloadSetup,
|
|
)
|
|
|
|
class Foreign(Exception):
|
|
category = 42
|
|
rate_limit_type = {"lol": "no"}
|
|
|
|
info = StandardLoggingPayloadSetup.get_error_information(Foreign())
|
|
assert info["error_rate_limit_category"] is None
|
|
assert info["error_rate_limit_type"] is None
|
|
|
|
def test_should_drop_garbage_on_prometheus_label_extraction(self):
|
|
from litellm.integrations.prometheus import PrometheusLogger
|
|
|
|
class Foreign(Exception):
|
|
category = "spam"
|
|
rate_limit_type = "spam"
|
|
|
|
category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(
|
|
Foreign()
|
|
)
|
|
assert category is None
|
|
assert rate_limit_type is None
|
|
|
|
def test_should_still_accept_legitimate_rate_limit_categories(self):
|
|
# The guard must not over-correct — every documented enum value
|
|
# is a valid string and must pass through.
|
|
from litellm.exceptions import (
|
|
validate_rate_limit_category,
|
|
validate_rate_limit_type,
|
|
)
|
|
|
|
for member in RateLimitErrorCategory:
|
|
assert validate_rate_limit_category(member.value) == member.value
|
|
assert validate_rate_limit_category(member) == member.value
|
|
|
|
for member in RateLimitType:
|
|
assert validate_rate_limit_type(member.value) == member.value
|
|
assert validate_rate_limit_type(member) == member.value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestBudgetExceededErrorLlmProviderEnrichment:
|
|
"""
|
|
BudgetExceededError raise sites in auth_checks.py are tenant-scoped
|
|
(key / team / org / tag) and cannot see the request model. To still
|
|
populate `llm_provider` on the StandardLoggingPayload — which is what
|
|
custom-callback consumers attribute spend to — the central
|
|
UserAPIKeyAuthExceptionHandler enriches the exception from
|
|
`request_data["model"]` before post_call_failure_hook fires.
|
|
"""
|
|
|
|
async def _run_handler_and_capture_exception_seen_by_callback(
|
|
self, exception: Exception, request_data: dict
|
|
):
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from litellm.proxy.auth.auth_exception_handler import (
|
|
UserAPIKeyAuthExceptionHandler,
|
|
)
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_post_call_failure_hook(**kwargs):
|
|
captured["exception"] = kwargs["original_exception"]
|
|
return None
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.proxy_server.proxy_logging_obj",
|
|
MagicMock(
|
|
post_call_failure_hook=AsyncMock(
|
|
side_effect=fake_post_call_failure_hook
|
|
)
|
|
),
|
|
),
|
|
patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"use_x_forwarded_for": False},
|
|
),
|
|
patch(
|
|
"litellm.proxy.auth.auth_exception_handler._get_request_ip_address",
|
|
return_value="127.0.0.1",
|
|
),
|
|
):
|
|
try:
|
|
await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
|
|
e=exception,
|
|
request=MagicMock(),
|
|
request_data=request_data,
|
|
route="/v1/chat/completions",
|
|
parent_otel_span=None,
|
|
api_key="sk-test",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return captured.get("exception")
|
|
|
|
async def test_should_resolve_llm_provider_from_request_data_when_unset(self):
|
|
err = litellm.BudgetExceededError(current_cost=100, max_budget=10)
|
|
assert err.llm_provider == ""
|
|
seen = await self._run_handler_and_capture_exception_seen_by_callback(
|
|
err, {"model": "openai/gpt-4o-mini"}
|
|
)
|
|
assert seen is not None
|
|
assert seen.llm_provider == "openai"
|
|
|
|
async def test_should_not_overwrite_llm_provider_when_caller_set_it(self):
|
|
err = litellm.BudgetExceededError(
|
|
current_cost=100, max_budget=10, llm_provider="anthropic"
|
|
)
|
|
seen = await self._run_handler_and_capture_exception_seen_by_callback(
|
|
err, {"model": "openai/gpt-4o-mini"}
|
|
)
|
|
assert seen.llm_provider == "anthropic"
|
|
|
|
async def test_should_fall_back_to_litellm_proxy_when_model_missing(self):
|
|
err = litellm.BudgetExceededError(current_cost=100, max_budget=10)
|
|
seen = await self._run_handler_and_capture_exception_seen_by_callback(err, {})
|
|
assert seen.llm_provider == "litellm_proxy"
|
|
|
|
async def test_should_not_enrich_non_budget_exceptions(self):
|
|
err = ValueError("unrelated")
|
|
seen = await self._run_handler_and_capture_exception_seen_by_callback(
|
|
err, {"model": "openai/gpt-4o-mini"}
|
|
)
|
|
assert not hasattr(seen, "llm_provider") or seen.llm_provider != "openai"
|