mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
Merge pull request #42097 from BerriAI/litellm_budget_exceeded_422
fix(proxy): return 422 instead of 429 for BudgetExceededError
This commit is contained in:
commit
def37c6532
19 changed files with 55 additions and 48 deletions
|
|
@ -400,6 +400,7 @@ default_redis_batch_cache_expiry: Optional[float] = None
|
|||
model_alias_map: Dict[str, str] = {}
|
||||
model_group_settings: Optional["ModelGroupSettings"] = None
|
||||
max_budget: float = 0.0 # set the max budget across all providers
|
||||
budget_exceeded_status_code: int = 422 # set to 429 to restore the pre-422 budget_exceeded response code
|
||||
budget_duration: Optional[str] = (
|
||||
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from typing import Any, Final
|
|||
import httpx
|
||||
import openai
|
||||
|
||||
import litellm
|
||||
from litellm.types.utils import LiteLLMCommonStrings
|
||||
from litellm.types.vector_stores import VectorStoreSearchFailure
|
||||
|
||||
|
|
@ -1002,7 +1003,7 @@ class BudgetExceededError(Exception):
|
|||
):
|
||||
self.current_cost = current_cost
|
||||
self.max_budget = max_budget
|
||||
self.status_code = 429
|
||||
self.status_code = litellm.budget_exceeded_status_code
|
||||
self.llm_provider = llm_provider or ""
|
||||
self.entity_type = entity_type
|
||||
self.entity_id = entity_id
|
||||
|
|
|
|||
|
|
@ -1154,7 +1154,7 @@ class MCPRequestHandler:
|
|||
|
||||
Failures surface with the status the standard pipeline would give them, mirroring
|
||||
``UserAPIKeyAuthExceptionHandler``: a disallowed route is the route gate's own 403, an
|
||||
over-budget identity is a 429, a sub-check that raised its own ``HTTPException``/
|
||||
over-budget identity is a 422, a sub-check that raised its own ``HTTPException``/
|
||||
``ProxyException`` keeps that status, a transient database outage is a retryable 503, and
|
||||
only a genuinely unresolvable failure (a blocked team/project raises a bare ``Exception``,
|
||||
same as the standard pipeline's fallback) becomes the fail-closed 401. Collapsing every
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class UnauthorizedError(BaseModel):
|
|||
class RateLimitedError(BaseModel):
|
||||
kind: Literal["rate_limited"] = "rate_limited"
|
||||
retry_after_seconds: int | None = None
|
||||
# litellm overloads 429 for budget_exceeded too, so keep the body to tell them apart.
|
||||
# keep the body so callers can tell limiter kinds apart.
|
||||
body: str = ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None:
|
|||
for _ in range(40):
|
||||
outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}")
|
||||
if _is_budget_block(outcome):
|
||||
assert outcome.status_code == 429, (
|
||||
f"budget refusal must be 429, got {outcome.status_code}: {outcome.body[:200]}"
|
||||
assert outcome.status_code == 422, (
|
||||
f"budget refusal must be 422, got {outcome.status_code}: {outcome.body[:200]}"
|
||||
)
|
||||
return
|
||||
assert outcome.ok, f"paid call failed before the budget tripped ({outcome.status_code}): {outcome.body[:300]}"
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
|
|||
pytest.fail("budget never enforced within the call budget")
|
||||
|
||||
|
||||
def _assert_blocked_429(client: BudgetClient, key: str) -> StreamingResponse:
|
||||
def _assert_blocked_422(client: BudgetClient, key: str) -> StreamingResponse:
|
||||
blocked = _assert_budget_blocks(client, key)
|
||||
assert blocked.status_code == 429, (
|
||||
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
assert blocked.status_code == 422, (
|
||||
f"budget refusal must be 422, got {blocked.status_code}: {blocked.body[:200]}"
|
||||
)
|
||||
return blocked
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ class TestBudgetBlocksPerLevel:
|
|||
key = client.generate_key(max_budget=TINY_CAP)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
_assert_blocked_429(client, key)
|
||||
_assert_blocked_422(client, key)
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.team.blocks_over_limit")
|
||||
def test_team_budget_blocks_every_team_key(self, client: BudgetClient, resources: ResourceManager) -> None:
|
||||
|
|
@ -71,10 +71,10 @@ class TestBudgetBlocksPerLevel:
|
|||
sibling_key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(sibling_key))
|
||||
|
||||
_assert_blocked_429(client, spender_key)
|
||||
_assert_blocked_422(client, spender_key)
|
||||
sibling = _chat(client, sibling_key)
|
||||
assert is_budget_block(sibling) and sibling.status_code == 429, (
|
||||
f"a sibling key on the capped team must get the same 429 budget_exceeded, "
|
||||
assert is_budget_block(sibling) and sibling.status_code == 422, (
|
||||
f"a sibling key on the capped team must get the same 422 budget_exceeded, "
|
||||
f"got {sibling.status_code}: {sibling.body[:200]}"
|
||||
)
|
||||
|
||||
|
|
@ -99,10 +99,10 @@ class TestBudgetBlocksPerLevel:
|
|||
team_key = client.generate_key(team_id=team_id, user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(team_key))
|
||||
|
||||
_assert_blocked_429(client, first_key)
|
||||
_assert_blocked_422(client, first_key)
|
||||
second = _chat(client, second_key)
|
||||
assert is_budget_block(second) and second.status_code == 429, (
|
||||
f"the second personal key of a user over budget must get the same 429 budget_exceeded, "
|
||||
assert is_budget_block(second) and second.status_code == 422, (
|
||||
f"the second personal key of a user over budget must get the same 422 budget_exceeded, "
|
||||
f"got {second.status_code}: {second.body[:200]}"
|
||||
)
|
||||
team_result = _chat(client, team_key)
|
||||
|
|
@ -133,7 +133,7 @@ class TestBudgetBlocksPerLevel:
|
|||
key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(key))
|
||||
|
||||
blocked = _assert_blocked_429(client, key)
|
||||
blocked = _assert_blocked_422(client, key)
|
||||
assert f"Organization={org_id}" in blocked.body, (
|
||||
f"refusal must name the org as the blocker, got: {blocked.body[:200]}"
|
||||
)
|
||||
|
|
@ -155,7 +155,7 @@ class TestBudgetBlocksPerLevel:
|
|||
teammate_key = client.generate_key(team_id=team_id, user_id=teammate_id)
|
||||
resources.defer(lambda: client.delete_key(teammate_key))
|
||||
|
||||
_assert_blocked_429(client, member_key)
|
||||
_assert_blocked_422(client, member_key)
|
||||
require_successful_call(_chat(client, teammate_key))
|
||||
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
control_key = client.generate_key(user_id=user_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
_assert_blocked_429(client, capped_key)
|
||||
_assert_blocked_422(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
|
|
@ -188,7 +188,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
control_key = client.generate_key(team_id=team_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
_assert_blocked_429(client, capped_key)
|
||||
_assert_blocked_422(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
||||
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
|
||||
|
|
@ -205,5 +205,5 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
|
|||
control_key = client.generate_key(team_id=team_id, user_id=member_id)
|
||||
resources.defer(lambda: client.delete_key(control_key))
|
||||
|
||||
_assert_blocked_429(client, capped_key)
|
||||
_assert_blocked_422(client, capped_key)
|
||||
require_successful_call(_chat(client, control_key))
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ def test_long_window_blocks_after_short_window_resets(client: BudgetClient, reso
|
|||
|
||||
# 1. drive the key to get blocked by SHORT_WINDOW, assert it's budget error
|
||||
blocked = _drive_to_block(client, key)
|
||||
assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
|
||||
assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
|
||||
|
||||
# 2. check the reset times of both budget windows after we drove to being blocked
|
||||
blocked_reset_at = window_reset_at(client.key_budget_windows(key), SHORT_WINDOW)
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ def test_team_long_window_blocks_after_short_window_resets(client: BudgetClient,
|
|||
|
||||
# 1. drive the key to being blocked, assert its blocked by budget budget_exceeded
|
||||
blocked = _drive_to_block(client, key)
|
||||
assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
|
||||
assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
|
||||
|
||||
# 2. check the the teams budget windows
|
||||
blocked_reset_at = window_reset_at(client.team_budget_windows(team_id), SHORT_WINDOW)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
|
|||
"POST", "/v1/chat/completions",
|
||||
{"model": models[0], "messages": [{"role": "user", "content": "zero budget"}]}, key=key,
|
||||
)
|
||||
assert denied.status_code == 429, denied.text
|
||||
assert denied.status_code == 422, denied.text
|
||||
assert denied.json()["error"]["type"] == "budget_exceeded"
|
||||
gateway.post("/key/update", {"key": key, "max_budget": 1, "models": [], "metadata": {}})
|
||||
info: Final = object_value(gateway.get("/key/info", {"key": key})["info"])
|
||||
|
|
@ -127,7 +127,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
|
|||
"POST", "/v1/chat/completions",
|
||||
{"model": models[0], "messages": [{"role": "user", "content": "updated zero budget"}]}, key=key,
|
||||
)
|
||||
assert zero_after_update.status_code == 429, zero_after_update.text
|
||||
assert zero_after_update.status_code == 422, zero_after_update.text
|
||||
assert zero_after_update.json()["error"]["type"] == "budget_exceeded"
|
||||
gateway.post("/key/update", {"key": key, "max_budget": None})
|
||||
assert read_rows(
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
|
|||
{"model": model, "messages": [{"role": "user", "content": f"over budget {uuid.uuid4().hex}"}]},
|
||||
key=key,
|
||||
)
|
||||
assert denied.status_code == 429 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
|
||||
assert denied.status_code == 422 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
|
||||
assert upstream.get("/__observations").json()["requests"] == []
|
||||
assert gateway.chat(model, key=control, text=f"control {uuid.uuid4().hex}")["usage"]["total_tokens"] == 40
|
||||
gateway.post("/key/update", {"key": key, "spend": 0})
|
||||
|
|
@ -205,7 +205,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
|
|||
{"model": model, "messages": [{"role": "user", "content": f"boundary again {uuid.uuid4().hex}"}]},
|
||||
key=key,
|
||||
)
|
||||
assert denied_again.status_code == 429 and denied_again.json()["error"]["type"] == "budget_exceeded", (
|
||||
assert denied_again.status_code == 422 and denied_again.json()["error"]["type"] == "budget_exceeded", (
|
||||
denied_again.text
|
||||
)
|
||||
assert upstream.get("/__observations").json()["requests"] == []
|
||||
|
|
|
|||
|
|
@ -3033,7 +3033,7 @@ def test_get_error_information_budget_exceeded_structured_fields():
|
|||
assert result["error_budget_entity_id"] == "repro-user"
|
||||
assert result["error_budget_limit"] == 1e-06
|
||||
assert result["error_budget_spend"] == 3.4e-05
|
||||
assert result["error_code"] == "429"
|
||||
assert result["error_code"] == "422"
|
||||
assert result["error_class"] == "BudgetExceededError"
|
||||
assert result["error_rate_limit_type"] == "budget"
|
||||
|
||||
|
|
@ -6407,7 +6407,7 @@ def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx():
|
|||
|
||||
|
||||
def test_get_error_information_skips_traceback_for_budget_rejection_with_provider():
|
||||
"""A key-over-budget 429 is the proxy's own rejection even after the auth
|
||||
"""A key-over-budget 422 is the proxy's own rejection even after the auth
|
||||
handler stamps the requested model's provider onto it, so it stays cheap."""
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
|
||||
|
|
@ -6416,7 +6416,7 @@ def test_get_error_information_skips_traceback_for_budget_rejection_with_provide
|
|||
litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
|
||||
)
|
||||
result = StandardLoggingPayloadSetup.get_error_information(over_budget)
|
||||
assert result["error_code"] == "429"
|
||||
assert result["error_code"] == "422"
|
||||
assert result["llm_provider"] == "anthropic"
|
||||
assert result["traceback"] == ""
|
||||
|
||||
|
|
|
|||
|
|
@ -6339,15 +6339,15 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
)
|
||||
return exc_info.value
|
||||
|
||||
async def test_over_budget_admission_surfaces_429_not_401(self):
|
||||
"""A validly-authenticated but over-budget identity surfaces the standard pipeline's 429, not
|
||||
async def test_over_budget_admission_surfaces_422_not_401(self):
|
||||
"""A validly-authenticated but over-budget identity surfaces the standard pipeline's 422, not
|
||||
a misleading 401. Flattening budget to 401 told the caller their credential was invalid, which
|
||||
on a DCR client reads as broken auth and triggers a re-authorize that cannot fix a budget
|
||||
problem. Regression for the status-flattening finding on the live-policy gate."""
|
||||
import litellm
|
||||
|
||||
mapped = await self._enforce_with_gate_error(litellm.BudgetExceededError(current_cost=10.0, max_budget=1.0))
|
||||
assert mapped.status_code == 429
|
||||
assert mapped.status_code == 422
|
||||
|
||||
async def test_db_outage_during_policy_surfaces_503_not_401(self):
|
||||
"""A transient database outage during the live-policy gate surfaces a retryable 503, not a 401
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ async def test_handle_authentication_error_budget_exceeded():
|
|||
)
|
||||
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
assert int(exc_info.value.code) == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
assert int(exc_info.value.code) == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -687,7 +687,7 @@ def _http_request(client_host: str | None = "10.1.2.3", headers: dict[str, str]
|
|||
{"allow_requests_on_db_unavailable": False},
|
||||
{},
|
||||
"10.1.2.3",
|
||||
id="429_budget_exceeded",
|
||||
id="422_budget_exceeded",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
@ -697,7 +697,7 @@ async def test_auth_failure_logs_requester_ip_address(
|
|||
request_kwargs: dict[str, dict[str, str]],
|
||||
expected_ip: str,
|
||||
) -> None:
|
||||
"""401s and budget 429s are rejected before `add_litellm_data_to_request` stamps
|
||||
"""401s and budget 422s are rejected before `add_litellm_data_to_request` stamps
|
||||
the caller IP, so without this the failure logs (spend logs, prometheus client_ip)
|
||||
had no IP, and a 401 rarely carries a key or user identity either."""
|
||||
with (
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ async def test_over_first_window_raises():
|
|||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.status_code == 429
|
||||
assert err.status_code == 422
|
||||
assert "24h" in str(err)
|
||||
assert "Key over" in str(err)
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ async def test_over_second_window_raises():
|
|||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.status_code == 429
|
||||
assert err.status_code == 422
|
||||
assert "30d" in str(err)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8156,7 +8156,7 @@ async def test_reset_key_spend_resets_budget_windows(monkeypatch):
|
|||
counter without also advancing reset_at is not durable either: the very
|
||||
next request would re-sum the unchanged historical spend and put the
|
||||
counter right back above the window's max_budget, so
|
||||
_virtual_key_multi_budget_check kept raising BudgetExceededError (429) on
|
||||
_virtual_key_multi_budget_check kept raising BudgetExceededError (422) on
|
||||
every request even though the key's own reported spend read $0.
|
||||
"""
|
||||
mock_prisma_client = MagicMock()
|
||||
|
|
@ -16593,7 +16593,7 @@ async def test_info_key_fn_reads_the_configured_budget_model_key(monkeypatch):
|
|||
|
||||
It used to probe a second, provider-stripped key because the counter was
|
||||
written under the request model instead, which is what let a key report zero
|
||||
usage while being blocked at 429.
|
||||
usage while being blocked at 422.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
|
|||
|
|
@ -2099,7 +2099,7 @@ class TestCursorVariantPerModelBudgetEnforcement:
|
|||
|
||||
response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-thinking-high")
|
||||
|
||||
assert response.status_code == 429, response.text
|
||||
assert response.status_code == 422, response.text
|
||||
error = response.json()["error"]
|
||||
assert error["type"] == "budget_exceeded"
|
||||
assert "exceeded budget for model=claude-opus-5" in error["message"]
|
||||
|
|
@ -2110,8 +2110,8 @@ class TestCursorVariantPerModelBudgetEnforcement:
|
|||
base_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5")
|
||||
alias_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-fast")
|
||||
|
||||
assert base_response.status_code == 429, base_response.text
|
||||
assert alias_response.status_code == 429, alias_response.text
|
||||
assert base_response.status_code == 422, base_response.text
|
||||
assert alias_response.status_code == 422, alias_response.text
|
||||
assert alias_response.json() == base_response.json()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ class TestProxyBaseLLMRequestProcessing:
|
|||
)
|
||||
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
assert exc_info.value.code == "429"
|
||||
assert exc_info.value.code == "422"
|
||||
tag_budget_check.assert_awaited_once()
|
||||
_, call_kwargs = tag_budget_check.call_args
|
||||
assert call_kwargs["tags"] == ("guardrail-tag",)
|
||||
|
|
@ -702,7 +702,7 @@ class TestProxyBaseLLMRequestProcessing:
|
|||
)
|
||||
|
||||
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
|
||||
assert exc_info.value.code == "429"
|
||||
assert exc_info.value.code == "422"
|
||||
assert "guardrail-tag" in exc_info.value.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -10872,7 +10872,7 @@ async def test_realtime_session_rejected_in_pre_call_releases_the_budget_reserva
|
|||
"""A rate-limit or guardrail rejection happens before route_request, so the
|
||||
relay never runs and no success log can own the reservation. The endpoint
|
||||
must release it on that exit too, or the key stays pinned at the reserved
|
||||
amount and its next requests 429 with budget_exceeded while /key/info shows
|
||||
amount and its next requests 422 with budget_exceeded while /key/info shows
|
||||
spend 0 (reproduced live with rpm_limit=1). The client still gets the
|
||||
pre-call error event and the 1011 close it got before."""
|
||||
reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []}
|
||||
|
|
|
|||
|
|
@ -1397,13 +1397,18 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
|
|||
assert e.llm_provider == "anthropic"
|
||||
|
||||
def test_should_keep_existing_status_code_and_message(self):
|
||||
# Backward-compat guard: existing callers depend on `status_code=429`
|
||||
# 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 == 429
|
||||
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:`
|
||||
|
|
@ -1424,7 +1429,7 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
|
|||
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"] == "429"
|
||||
assert info["error_code"] == "422"
|
||||
assert info["error_class"] == "BudgetExceededError"
|
||||
|
||||
def test_should_propagate_llm_provider_to_standard_logging_payload(self):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue